@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
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# @getsnare/mcp
|
|
2
|
+
|
|
3
|
+
Snare's MCP server. Point any MCP client at it to read and act on issues, events, traces and fix runs — or to run a Snare on your own machine, with your own model.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
You need a Snare token. Make one at [snare.dev/settings/api-tokens](https://snare.dev/settings/api-tokens).
|
|
8
|
+
|
|
9
|
+
### Claude Code
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
claude mcp add snare --env SNARE_API_TOKEN=snr_... -- npx -y @getsnare/mcp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Cursor, Windsurf, Codex, opencode
|
|
16
|
+
|
|
17
|
+
Add this to the client's MCP config file:
|
|
18
|
+
|
|
19
|
+
```json
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"snare": {
|
|
23
|
+
"command": "npx",
|
|
24
|
+
"args": ["-y", "@getsnare/mcp"],
|
|
25
|
+
"env": { "SNARE_API_TOKEN": "snr_..." }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Over HTTP
|
|
32
|
+
|
|
33
|
+
For clients that take a URL instead of a command:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
https://snare.dev/api/mcp
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Send the token as `Authorization: Bearer snr_...`.
|
|
40
|
+
|
|
41
|
+
## What it can do
|
|
42
|
+
|
|
43
|
+
| Set | Tools |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `issues` | List, search, read, comment, assign, change status and severity, merge, split, see who is in the workspace |
|
|
46
|
+
| `events` | Occurrences with their stacks, traces, session recordings, affected users |
|
|
47
|
+
| `snares` | Launch a fix run, watch it, answer its questions, steer it, stop it, read its diff, check what is left on the plan |
|
|
48
|
+
| `local` | Run a Snare here, on your own agent |
|
|
49
|
+
| `feedback` | Bug reports and feature requests from your users |
|
|
50
|
+
| `memory` | Project rules, standing instructions, what Snare has learned |
|
|
51
|
+
| `workspace` | Projects and how the workspace is doing |
|
|
52
|
+
| `setup` | Install the SDK |
|
|
53
|
+
|
|
54
|
+
All of them are on by default. Narrow it with `SNARE_MCP_TOOLSETS` when you want a smaller catalogue:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
SNARE_MCP_TOOLSETS=issues,events
|
|
58
|
+
SNARE_MCP_TOOLSETS=all
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Every tool's description is text the model reads on every turn, so narrowing leaves more room for the problem you are working on. Turning a set off means the model cannot see it at all — it will say Snare cannot do that, rather than that you switched it off.
|
|
62
|
+
|
|
63
|
+
## Permissions
|
|
64
|
+
|
|
65
|
+
A token carries scopes, and the server only registers tools that token can use. A read-only token sees read-only tools; the rest are not offered at all, so the model never picks one it will be refused for.
|
|
66
|
+
|
|
67
|
+
Two tools spend a Snare from your plan: `launch_snare` and `start_local_snare`. The token page marks both scopes.
|
|
68
|
+
|
|
69
|
+
Pick scopes when you make the token. **Read only** suits an agent that answers questions about production. **Triage** adds commenting, assigning and status changes without spending anything.
|
|
70
|
+
|
|
71
|
+
## Running a Snare locally
|
|
72
|
+
|
|
73
|
+
`start_local_snare` runs Snare's fix loop against the repository you have open, using your own model.
|
|
74
|
+
|
|
75
|
+
Snare decides the stages, checks each answer against that stage's contract, scores the result and meters the run. Your agent does the reading and the editing. It is the same loop as a cloud run — the model and the machine are yours.
|
|
76
|
+
|
|
77
|
+
Your agent calls `start_local_snare`, then `local_snare_step` in a loop until the run reports `done`. Each instruction says what to do and what to send back.
|
|
78
|
+
|
|
79
|
+
Three things to know before you start one:
|
|
80
|
+
|
|
81
|
+
- **It changes files in your working tree.** Commit or stash first.
|
|
82
|
+
- **It spends a Snare** from your plan, the same as a cloud run.
|
|
83
|
+
- **Snare cannot enforce its safety rules on your machine.** In its own sandbox they block a command before it runs. Here they are instructions to your agent. Snare will not ask your agent to do something it would have blocked, but it cannot stop your agent doing it anyway.
|
|
84
|
+
|
|
85
|
+
Snare Lite and investigations do not run locally yet. Use `launch_snare` for those.
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
| Variable | Default |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `SNARE_API_TOKEN` | required |
|
|
92
|
+
| `SNARE_MCP_TOOLSETS` | `all` |
|
|
93
|
+
| `SNARE_BASE_URL` | `https://snare.dev/api/v1` |
|
|
94
|
+
| `SNARE_DASHBOARD_URL` | derived from `SNARE_BASE_URL` |
|
|
95
|
+
|
|
96
|
+
Each has a command-line equivalent (`--token`, `--toolsets`, `--base-url`, `--dashboard-url`) which takes precedence.
|
|
97
|
+
|
|
98
|
+
## Licence
|
|
99
|
+
|
|
100
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { buildConfig, ConfigError } from "../src/config.js";
|
|
4
|
+
import { createSnareMcpServer } from "../src/server.js";
|
|
5
|
+
/**
|
|
6
|
+
* The stdio server.
|
|
7
|
+
*
|
|
8
|
+
* NOTHING MAY BE WRITTEN TO STDOUT EXCEPT PROTOCOL TRAFFIC. A single stray
|
|
9
|
+
* `console.log` corrupts the JSON-RPC stream, and the failure surfaces in the
|
|
10
|
+
* client as "the server disconnected" with no indication of why. Every message
|
|
11
|
+
* here goes to stderr, which clients show in their log panel.
|
|
12
|
+
*
|
|
13
|
+
* A CONFIG PROBLEM EXITS WITH ONE SENTENCE, not a stack. This is the first
|
|
14
|
+
* thing a new user hits, usually inside a client that shows them one line, and
|
|
15
|
+
* "SNARE_API_TOKEN is not set — get one at ..." is worth more than forty lines
|
|
16
|
+
* of frames.
|
|
17
|
+
*/
|
|
18
|
+
function readArg(name) {
|
|
19
|
+
const prefix = `--${name}=`;
|
|
20
|
+
const inline = process.argv.find((arg) => arg.startsWith(prefix));
|
|
21
|
+
if (inline)
|
|
22
|
+
return inline.slice(prefix.length);
|
|
23
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
24
|
+
return index >= 0 ? process.argv[index + 1] : undefined;
|
|
25
|
+
}
|
|
26
|
+
async function main() {
|
|
27
|
+
// Argv beats env, so a client that pins a token in its own config file wins
|
|
28
|
+
// over whatever happens to be in the shell that launched it.
|
|
29
|
+
const config = buildConfig({
|
|
30
|
+
token: readArg("token") ?? process.env.SNARE_API_TOKEN,
|
|
31
|
+
baseUrl: readArg("base-url") ?? process.env.SNARE_BASE_URL,
|
|
32
|
+
dashboardUrl: readArg("dashboard-url") ?? process.env.SNARE_DASHBOARD_URL,
|
|
33
|
+
toolsets: readArg("toolsets") ?? process.env.SNARE_MCP_TOOLSETS,
|
|
34
|
+
});
|
|
35
|
+
const { server, identity } = await createSnareMcpServer(config);
|
|
36
|
+
// stderr, always. See the header.
|
|
37
|
+
console.error(`snare-mcp ready${identity.organization ? ` for ${identity.organization.name}` : ""} ` +
|
|
38
|
+
`(${identity.scopes.length} scopes, sets: ${config.toolsets.join(", ")})`);
|
|
39
|
+
await server.connect(new StdioServerTransport());
|
|
40
|
+
}
|
|
41
|
+
main().catch((err) => {
|
|
42
|
+
if (err instanceof ConfigError) {
|
|
43
|
+
console.error(err.message);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { McpConfig } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* The only way this package talks to Snare.
|
|
4
|
+
*
|
|
5
|
+
* NO DATABASE, EVER. This server runs on a customer's machine. A database
|
|
6
|
+
* driver in this process would mean shipping a production connection string to
|
|
7
|
+
* everybody who runs `npx`, and the trust boundary would be "whatever the code
|
|
8
|
+
* remembers not to select" rather than a token with scopes on it. Everything
|
|
9
|
+
* goes through `/api/v1`, which is the same door the dashboard, the mobile app
|
|
10
|
+
* and any CI script use.
|
|
11
|
+
*
|
|
12
|
+
* WHICH ALSO MEANS THE TWO CANNOT DRIFT. There is no second implementation of
|
|
13
|
+
* "what an issue looks like" to keep in step with the API's, because there is
|
|
14
|
+
* no second implementation.
|
|
15
|
+
*/
|
|
16
|
+
/** A refusal from the API, carrying the machine-readable code the routes return. */
|
|
17
|
+
export declare class SnareApiError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
readonly code: string;
|
|
20
|
+
constructor(status: number, code: string, message: string);
|
|
21
|
+
}
|
|
22
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
23
|
+
export declare class SnareClient {
|
|
24
|
+
private readonly config;
|
|
25
|
+
private readonly fetchImpl;
|
|
26
|
+
constructor(config: McpConfig, fetchImpl?: FetchLike);
|
|
27
|
+
get dashboardUrl(): string;
|
|
28
|
+
get<T>(path: string, query?: Record<string, string | number | boolean | undefined>): Promise<T>;
|
|
29
|
+
post<T>(path: string, body?: unknown, options?: {
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
retry?: boolean;
|
|
32
|
+
}): Promise<T>;
|
|
33
|
+
patch<T>(path: string, body?: unknown): Promise<T>;
|
|
34
|
+
put<T>(path: string, body?: unknown): Promise<T>;
|
|
35
|
+
delete<T>(path: string, body?: unknown): Promise<T>;
|
|
36
|
+
private request;
|
|
37
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only way this package talks to Snare.
|
|
3
|
+
*
|
|
4
|
+
* NO DATABASE, EVER. This server runs on a customer's machine. A database
|
|
5
|
+
* driver in this process would mean shipping a production connection string to
|
|
6
|
+
* everybody who runs `npx`, and the trust boundary would be "whatever the code
|
|
7
|
+
* remembers not to select" rather than a token with scopes on it. Everything
|
|
8
|
+
* goes through `/api/v1`, which is the same door the dashboard, the mobile app
|
|
9
|
+
* and any CI script use.
|
|
10
|
+
*
|
|
11
|
+
* WHICH ALSO MEANS THE TWO CANNOT DRIFT. There is no second implementation of
|
|
12
|
+
* "what an issue looks like" to keep in step with the API's, because there is
|
|
13
|
+
* no second implementation.
|
|
14
|
+
*/
|
|
15
|
+
/** A refusal from the API, carrying the machine-readable code the routes return. */
|
|
16
|
+
export class SnareApiError extends Error {
|
|
17
|
+
status;
|
|
18
|
+
code;
|
|
19
|
+
constructor(status, code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.status = status;
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.name = "SnareApiError";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Statuses worth trying again.
|
|
28
|
+
*
|
|
29
|
+
* A 5xx is the server having a bad moment and a 429 is a rate limit that will
|
|
30
|
+
* pass. Everything else — a bad request, a missing scope, a row that is not
|
|
31
|
+
* there — will fail identically the second time, and retrying it turns one
|
|
32
|
+
* clear refusal into three and a longer wait before the model sees it.
|
|
33
|
+
*/
|
|
34
|
+
function retryable(status) {
|
|
35
|
+
return status === 429 || status >= 500;
|
|
36
|
+
}
|
|
37
|
+
const ATTEMPTS = 3;
|
|
38
|
+
export class SnareClient {
|
|
39
|
+
config;
|
|
40
|
+
fetchImpl;
|
|
41
|
+
constructor(config, fetchImpl = globalThis.fetch) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
this.fetchImpl = fetchImpl;
|
|
44
|
+
}
|
|
45
|
+
get dashboardUrl() {
|
|
46
|
+
return this.config.dashboardUrl;
|
|
47
|
+
}
|
|
48
|
+
get(path, query) {
|
|
49
|
+
return this.request("GET", withQuery(path, query));
|
|
50
|
+
}
|
|
51
|
+
post(path, body, options) {
|
|
52
|
+
return this.request("POST", path, body, options);
|
|
53
|
+
}
|
|
54
|
+
patch(path, body) {
|
|
55
|
+
return this.request("PATCH", path, body);
|
|
56
|
+
}
|
|
57
|
+
put(path, body) {
|
|
58
|
+
return this.request("PUT", path, body);
|
|
59
|
+
}
|
|
60
|
+
delete(path, body) {
|
|
61
|
+
return this.request("DELETE", path, body);
|
|
62
|
+
}
|
|
63
|
+
async request(method, path, body, options) {
|
|
64
|
+
const url = `${this.config.baseUrl}${path}`;
|
|
65
|
+
const timeoutMs = options?.timeoutMs ?? this.config.requestTimeoutMs;
|
|
66
|
+
// Retrying is right for a stateless read and wrong for a call that is
|
|
67
|
+
// already a long poll: a local run's step holds for up to twenty-five
|
|
68
|
+
// seconds by design, so three attempts against a slow or unreachable
|
|
69
|
+
// orchestrator is a tool call that hangs for two minutes and then reports
|
|
70
|
+
// a failure the first attempt already knew about. Measured, not guessed —
|
|
71
|
+
// a live pass against a stopped orchestrator produced exactly that.
|
|
72
|
+
const attempts = options?.retry === false ? 1 : ATTEMPTS;
|
|
73
|
+
let lastError = null;
|
|
74
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await this.fetchImpl(url, {
|
|
78
|
+
method,
|
|
79
|
+
headers: {
|
|
80
|
+
authorization: `Bearer ${this.config.token}`,
|
|
81
|
+
"content-type": "application/json",
|
|
82
|
+
// Named so a customer reading their own audit log can tell a call
|
|
83
|
+
// from their editor apart from one from a CI script.
|
|
84
|
+
"user-agent": "snare-mcp",
|
|
85
|
+
},
|
|
86
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
87
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// A network failure or a timeout. Retried like a 5xx, and reported
|
|
92
|
+
// without the underlying message, which is usually a DNS error nobody
|
|
93
|
+
// can act on.
|
|
94
|
+
lastError = new SnareApiError(0, "unreachable", "Could not reach Snare.");
|
|
95
|
+
if (attempt === attempts)
|
|
96
|
+
throw lastError;
|
|
97
|
+
await backoff(attempt);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (response.ok) {
|
|
101
|
+
if (response.status === 204)
|
|
102
|
+
return undefined;
|
|
103
|
+
return (await response.json());
|
|
104
|
+
}
|
|
105
|
+
const payload = (await response.json().catch(() => null));
|
|
106
|
+
lastError = new SnareApiError(response.status, payload?.code ?? "unknown",
|
|
107
|
+
// The server's own sentence, because the routes are written to be read:
|
|
108
|
+
// a missing scope names the scope, a conflict says what is in the way.
|
|
109
|
+
// Inventing a message here would replace a specific one with a generic.
|
|
110
|
+
payload?.error ?? `Snare answered ${response.status}.`);
|
|
111
|
+
if (!retryable(response.status) || attempt === attempts)
|
|
112
|
+
throw lastError;
|
|
113
|
+
await backoff(attempt);
|
|
114
|
+
}
|
|
115
|
+
throw lastError ?? new SnareApiError(0, "unknown", "Could not reach Snare.");
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function backoff(attempt) {
|
|
119
|
+
// 200ms, then 400ms. Short on purpose: a person is waiting on the other end
|
|
120
|
+
// of this, and a retry schedule measured in seconds turns a transient blip
|
|
121
|
+
// into an agent that appears to have hung.
|
|
122
|
+
return new Promise((resolve) => setTimeout(resolve, 200 * 2 ** (attempt - 1)));
|
|
123
|
+
}
|
|
124
|
+
function withQuery(path, query) {
|
|
125
|
+
if (!query)
|
|
126
|
+
return path;
|
|
127
|
+
const params = new URLSearchParams();
|
|
128
|
+
for (const [key, value] of Object.entries(query)) {
|
|
129
|
+
// Undefined means "not asked for" and must not become the string
|
|
130
|
+
// "undefined", which every one of these routes would reject as a bad value.
|
|
131
|
+
if (value === undefined)
|
|
132
|
+
continue;
|
|
133
|
+
params.set(key, String(value));
|
|
134
|
+
}
|
|
135
|
+
const encoded = params.toString();
|
|
136
|
+
return encoded ? `${path}?${encoded}` : path;
|
|
137
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type Toolset } from "./toolsets.js";
|
|
2
|
+
/**
|
|
3
|
+
* What the server needs to know before it can do anything.
|
|
4
|
+
*
|
|
5
|
+
* ONE OBJECT, PASSED IN, NEVER READ FROM `process.env` DEEPER IN. The stdio
|
|
6
|
+
* binary reads the environment; the remote transport reads a request header.
|
|
7
|
+
* Anything below this file that reached for `process.env` would work in one of
|
|
8
|
+
* those and silently do nothing in the other, and the failure would look like a
|
|
9
|
+
* broken token rather than a missing branch.
|
|
10
|
+
*/
|
|
11
|
+
export interface McpConfig {
|
|
12
|
+
/** Where the API lives, without a trailing slash. */
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
/** The `snr_` token. Never logged, never included in an error message. */
|
|
15
|
+
token: string;
|
|
16
|
+
/** Which groups of tools to register. See `toolsets.ts`. */
|
|
17
|
+
toolsets: Toolset[];
|
|
18
|
+
/** How long any one API call may take. */
|
|
19
|
+
requestTimeoutMs: number;
|
|
20
|
+
/** Where a person opens what a tool is describing. */
|
|
21
|
+
dashboardUrl: string;
|
|
22
|
+
}
|
|
23
|
+
export declare const DEFAULT_BASE_URL = "https://snare.dev/api/v1";
|
|
24
|
+
export declare const DEFAULT_DASHBOARD_URL = "https://snare.dev";
|
|
25
|
+
/**
|
|
26
|
+
* How long a call may take.
|
|
27
|
+
*
|
|
28
|
+
* Thirty seconds rather than the default none. Every tool here is one HTTP
|
|
29
|
+
* round trip to a route that does a handful of queries, so a call still running
|
|
30
|
+
* after thirty seconds is a call that is not coming back — and an MCP client
|
|
31
|
+
* with no timeout of its own will sit on it forever, which reads to the person
|
|
32
|
+
* watching as the agent having stopped.
|
|
33
|
+
*
|
|
34
|
+
* The local-run step tool overrides this: it deliberately blocks for up to
|
|
35
|
+
* twenty-five seconds on the server side before answering, so its own ceiling
|
|
36
|
+
* has to be higher than that or the poll would time out exactly when it worked.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
39
|
+
export declare class ConfigError extends Error {
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Builds a config, or explains what is missing.
|
|
43
|
+
*
|
|
44
|
+
* THE MESSAGE MATTERS MORE THAN THE VALIDATION. This is the first thing a new
|
|
45
|
+
* user hits, usually inside a client that shows them one line of stderr, so a
|
|
46
|
+
* missing token says which variable to set and where to get one rather than
|
|
47
|
+
* throwing a stack trace about an undefined property.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildConfig(input: {
|
|
50
|
+
token?: string | undefined;
|
|
51
|
+
baseUrl?: string | undefined;
|
|
52
|
+
dashboardUrl?: string | undefined;
|
|
53
|
+
toolsets?: string | undefined;
|
|
54
|
+
requestTimeoutMs?: number | undefined;
|
|
55
|
+
}): McpConfig;
|
|
56
|
+
/**
|
|
57
|
+
* Which groups to turn on.
|
|
58
|
+
*
|
|
59
|
+
* An unknown name is refused rather than ignored. Ignoring it means somebody
|
|
60
|
+
* who typed `issue` instead of `issues` gets the default set, sees roughly the
|
|
61
|
+
* tools they expected, and never finds out their configuration did nothing.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parseToolsets(raw: string | undefined): Toolset[];
|
|
64
|
+
/**
|
|
65
|
+
* What a caller gets without asking: everything.
|
|
66
|
+
*
|
|
67
|
+
* IT USED TO BE FOUR SETS, on the reasoning that every registered tool is
|
|
68
|
+
* schema a model reads on every single turn, so a catalogue nobody is going to
|
|
69
|
+
* call costs context that could have gone on the bug. That reasoning is sound
|
|
70
|
+
* and it was still the wrong default, for a reason only visible once an agent
|
|
71
|
+
* used it: a model cannot tell "Snare does not do this" from "that set is not
|
|
72
|
+
* switched on", and it says the first. Asked what people were requesting, an
|
|
73
|
+
* agent answered that Snare has no feature-request surface at all — a false
|
|
74
|
+
* statement about the product, made confidently, to a customer. Another could
|
|
75
|
+
* not assign an issue because the tool for finding who to assign to was in a
|
|
76
|
+
* set nobody turns on.
|
|
77
|
+
*
|
|
78
|
+
* The cost of the old default was paid by everybody who did not know to change
|
|
79
|
+
* it, and it was paid in wrong answers. The cost of this one is context, on the
|
|
80
|
+
* turns where a set goes unused, and it is one environment variable to reclaim:
|
|
81
|
+
* `SNARE_MCP_TOOLSETS=issues,events` narrows it to whatever a caller actually
|
|
82
|
+
* wants.
|
|
83
|
+
*/
|
|
84
|
+
export declare const DEFAULT_TOOLSETS: readonly Toolset[];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { TOOLSETS } from "./toolsets.js";
|
|
2
|
+
export const DEFAULT_BASE_URL = "https://snare.dev/api/v1";
|
|
3
|
+
export const DEFAULT_DASHBOARD_URL = "https://snare.dev";
|
|
4
|
+
/**
|
|
5
|
+
* How long a call may take.
|
|
6
|
+
*
|
|
7
|
+
* Thirty seconds rather than the default none. Every tool here is one HTTP
|
|
8
|
+
* round trip to a route that does a handful of queries, so a call still running
|
|
9
|
+
* after thirty seconds is a call that is not coming back — and an MCP client
|
|
10
|
+
* with no timeout of its own will sit on it forever, which reads to the person
|
|
11
|
+
* watching as the agent having stopped.
|
|
12
|
+
*
|
|
13
|
+
* The local-run step tool overrides this: it deliberately blocks for up to
|
|
14
|
+
* twenty-five seconds on the server side before answering, so its own ceiling
|
|
15
|
+
* has to be higher than that or the poll would time out exactly when it worked.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
18
|
+
export class ConfigError extends Error {
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Builds a config, or explains what is missing.
|
|
22
|
+
*
|
|
23
|
+
* THE MESSAGE MATTERS MORE THAN THE VALIDATION. This is the first thing a new
|
|
24
|
+
* user hits, usually inside a client that shows them one line of stderr, so a
|
|
25
|
+
* missing token says which variable to set and where to get one rather than
|
|
26
|
+
* throwing a stack trace about an undefined property.
|
|
27
|
+
*/
|
|
28
|
+
export function buildConfig(input) {
|
|
29
|
+
const token = input.token?.trim();
|
|
30
|
+
if (!token) {
|
|
31
|
+
throw new ConfigError("No Snare token. Set SNARE_API_TOKEN to a token from https://snare.dev/settings/api-tokens.");
|
|
32
|
+
}
|
|
33
|
+
if (!token.startsWith("snr_")) {
|
|
34
|
+
// Caught here rather than at the first 401, because "that token is not
|
|
35
|
+
// usable" from the server is deliberately vague and would send somebody
|
|
36
|
+
// looking for a revoked token when they had actually pasted a project key.
|
|
37
|
+
throw new ConfigError('That does not look like a Snare token. They begin with "snr_".');
|
|
38
|
+
}
|
|
39
|
+
const baseUrl = (input.baseUrl?.trim() || DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
40
|
+
const dashboardUrl = (input.dashboardUrl?.trim() || deriveDashboardUrl(baseUrl)).replace(/\/$/, "");
|
|
41
|
+
return {
|
|
42
|
+
token,
|
|
43
|
+
baseUrl,
|
|
44
|
+
dashboardUrl,
|
|
45
|
+
toolsets: parseToolsets(input.toolsets),
|
|
46
|
+
requestTimeoutMs: input.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The dashboard is the API's base with the API path taken off.
|
|
51
|
+
*
|
|
52
|
+
* Derived rather than required, so somebody pointing this at a staging
|
|
53
|
+
* deployment sets one variable instead of two and cannot set them
|
|
54
|
+
* inconsistently. `SNARE_DASHBOARD_URL` overrides it for the case where they
|
|
55
|
+
* really are on different hosts.
|
|
56
|
+
*/
|
|
57
|
+
function deriveDashboardUrl(baseUrl) {
|
|
58
|
+
return baseUrl.replace(/\/api\/v1$/, "") || DEFAULT_DASHBOARD_URL;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Which groups to turn on.
|
|
62
|
+
*
|
|
63
|
+
* An unknown name is refused rather than ignored. Ignoring it means somebody
|
|
64
|
+
* who typed `issue` instead of `issues` gets the default set, sees roughly the
|
|
65
|
+
* tools they expected, and never finds out their configuration did nothing.
|
|
66
|
+
*/
|
|
67
|
+
export function parseToolsets(raw) {
|
|
68
|
+
const value = raw?.trim();
|
|
69
|
+
if (!value)
|
|
70
|
+
return [...DEFAULT_TOOLSETS];
|
|
71
|
+
if (value === "all")
|
|
72
|
+
return [...TOOLSETS];
|
|
73
|
+
const names = value
|
|
74
|
+
.split(",")
|
|
75
|
+
.map((name) => name.trim())
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
const unknown = names.filter((name) => !TOOLSETS.includes(name));
|
|
78
|
+
if (unknown.length > 0) {
|
|
79
|
+
throw new ConfigError(`Unknown tool set: ${unknown.join(", ")}. Pick from ${TOOLSETS.join(", ")}, or "all".`);
|
|
80
|
+
}
|
|
81
|
+
return names;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* What a caller gets without asking: everything.
|
|
85
|
+
*
|
|
86
|
+
* IT USED TO BE FOUR SETS, on the reasoning that every registered tool is
|
|
87
|
+
* schema a model reads on every single turn, so a catalogue nobody is going to
|
|
88
|
+
* call costs context that could have gone on the bug. That reasoning is sound
|
|
89
|
+
* and it was still the wrong default, for a reason only visible once an agent
|
|
90
|
+
* used it: a model cannot tell "Snare does not do this" from "that set is not
|
|
91
|
+
* switched on", and it says the first. Asked what people were requesting, an
|
|
92
|
+
* agent answered that Snare has no feature-request surface at all — a false
|
|
93
|
+
* statement about the product, made confidently, to a customer. Another could
|
|
94
|
+
* not assign an issue because the tool for finding who to assign to was in a
|
|
95
|
+
* set nobody turns on.
|
|
96
|
+
*
|
|
97
|
+
* The cost of the old default was paid by everybody who did not know to change
|
|
98
|
+
* it, and it was paid in wrong answers. The cost of this one is context, on the
|
|
99
|
+
* turns where a set goes unused, and it is one environment variable to reclaim:
|
|
100
|
+
* `SNARE_MCP_TOOLSETS=issues,events` narrows it to whatever a caller actually
|
|
101
|
+
* wants.
|
|
102
|
+
*/
|
|
103
|
+
export const DEFAULT_TOOLSETS = [...TOOLSETS];
|
|
@@ -0,0 +1,126 @@
|
|
|
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 declare const DASH = "\u2014";
|
|
25
|
+
/** Sentence case for an enum. `IN_PROGRESS` is a column value, "In progress" is language. */
|
|
26
|
+
export declare function label(value: string | null | undefined): string;
|
|
27
|
+
/**
|
|
28
|
+
* A duration in human units, never a clock and never raw seconds.
|
|
29
|
+
*
|
|
30
|
+
* Two units at most: "3 hrs 15 min", not "3 hrs 15 min 47 s". The third unit is
|
|
31
|
+
* always noise at the scale the second one is already showing, and a reader
|
|
32
|
+
* skimming a list stops at the first two anyway.
|
|
33
|
+
*/
|
|
34
|
+
export declare function duration(seconds: number | null | undefined): string;
|
|
35
|
+
/**
|
|
36
|
+
* How far away a moment is, in either direction.
|
|
37
|
+
*
|
|
38
|
+
* The ISO timestamp is always alongside it: this is the part somebody reads,
|
|
39
|
+
* that is the part they compute with.
|
|
40
|
+
*
|
|
41
|
+
* THE FUTURE IS A REAL CASE HERE and used to be clamped to zero, which rendered
|
|
42
|
+
* every one of them as "just now". Not everything this formats has happened:
|
|
43
|
+
* a billing period ends, a trial expires, a question's window closes. "This
|
|
44
|
+
* period ends 2026-10-07 (just now)" is a sentence that contradicts itself, and
|
|
45
|
+
* the reader it misleads is the one deciding whether they can afford to start
|
|
46
|
+
* a run.
|
|
47
|
+
*/
|
|
48
|
+
export declare function ago(iso: string | null | undefined, now?: Date): string;
|
|
49
|
+
/**
|
|
50
|
+
* A moment, to the minute, without the machine punctuation.
|
|
51
|
+
*
|
|
52
|
+
* `2026-09-06 08:52` rather than `2026-09-06T08:52:58.296Z`. The `T`, the
|
|
53
|
+
* seconds and the milliseconds are three pieces of precision no answer about an
|
|
54
|
+
* issue has ever needed, and in a column of forty rows they are most of the
|
|
55
|
+
* width. Always UTC, because a server-rendered local time is a time in a
|
|
56
|
+
* timezone the reader cannot see.
|
|
57
|
+
*/
|
|
58
|
+
export declare function stamp(iso: string | null | undefined): string;
|
|
59
|
+
/**
|
|
60
|
+
* A moment AND how long ago it was: `2026-09-06 08:52 UTC (1 d ago)`.
|
|
61
|
+
*
|
|
62
|
+
* For detail cards, where there is room for both and both get used. "Last seen"
|
|
63
|
+
* answers two different questions — is this still happening, and exactly when
|
|
64
|
+
* did it last happen — and a bare timestamp only answers the second, leaving
|
|
65
|
+
* the model to do date arithmetic in prose. It gets that wrong often enough
|
|
66
|
+
* that giving it the answer is worth the twelve characters.
|
|
67
|
+
*
|
|
68
|
+
* `ago()` alone is not enough here for the opposite reason: "3 d ago" cannot be
|
|
69
|
+
* quoted into a report, correlated with a deploy, or compared with anything.
|
|
70
|
+
*/
|
|
71
|
+
export declare function when(iso: string | null | undefined, now?: Date): string;
|
|
72
|
+
/**
|
|
73
|
+
* A quantity that agrees with its noun: "1 occurrence", "4 occurrences".
|
|
74
|
+
*
|
|
75
|
+
* `count()` above covers "12 of 340 issues". This covers the plain case, which
|
|
76
|
+
* was being written inline as `${n} issues` in six places and produced
|
|
77
|
+
* "1 occurrences", "1 issues" and "1 sessions" in real output. A model reading
|
|
78
|
+
* that is fine; a person reading the sentence the model then writes is reading
|
|
79
|
+
* a mistake the product made, and the fix is one function rather than six
|
|
80
|
+
* ternaries nobody will remember to add the seventh time.
|
|
81
|
+
*/
|
|
82
|
+
export declare function quantity(amount: number, noun: string, plural?: string): string;
|
|
83
|
+
/**
|
|
84
|
+
* A count that says what it is out of.
|
|
85
|
+
*
|
|
86
|
+
* "12 of 340 issues", never "12". A list that does not say what it is a slice of
|
|
87
|
+
* is a list whose answer changes meaning depending on something the reader
|
|
88
|
+
* cannot see, and an agent reporting "there are 12 open issues" from a page of
|
|
89
|
+
* 12 has said something false.
|
|
90
|
+
*/
|
|
91
|
+
export declare function count(shown: number, total: number, noun: string, plural?: string): string;
|
|
92
|
+
/** `Label: value`, with absence rendered as the dash rather than omitted. */
|
|
93
|
+
export declare function line(name: string, value: string | number | null | undefined): string;
|
|
94
|
+
/** A block of `Label: value` lines, blanks dropped, in the order given. */
|
|
95
|
+
export declare function facts(entries: Array<[string, string | number | null | undefined]>): string;
|
|
96
|
+
/**
|
|
97
|
+
* A tool's whole answer: one summary line, a blank, then the body.
|
|
98
|
+
*
|
|
99
|
+
* The summary exists so a model that reads nothing else still knows what it got.
|
|
100
|
+
* It is also what a client renders in a collapsed tool-call row, which is the
|
|
101
|
+
* only thing many readers will ever see of this.
|
|
102
|
+
*/
|
|
103
|
+
export declare function toolText(summary: string, ...blocks: Array<string | null | undefined>): string;
|
|
104
|
+
/** A bulleted list, or a sentence saying there is nothing. */
|
|
105
|
+
export declare function bullets(items: readonly string[], emptyMessage: string): string;
|
|
106
|
+
/**
|
|
107
|
+
* Truncates a long free-text field for a list row.
|
|
108
|
+
*
|
|
109
|
+
* A stack trace or a comment thread pasted whole into a fifty-row listing is
|
|
110
|
+
* the single easiest way to fill a context window with something nobody asked
|
|
111
|
+
* for. Detail calls return the whole thing; lists return this.
|
|
112
|
+
*/
|
|
113
|
+
export declare function clip(text: string | null | undefined, max?: number): string;
|
|
114
|
+
/** A percentage from a 0-1 score, or the dash. Confidence and similarity both use this. */
|
|
115
|
+
export declare function percent(value: number | null | undefined): string;
|
|
116
|
+
/**
|
|
117
|
+
* A whole number with thousands separators.
|
|
118
|
+
*
|
|
119
|
+
* `50000` and `49953` next to each other in one sentence are two five-digit
|
|
120
|
+
* runs a reader has to count digits to tell apart, and a model asked to
|
|
121
|
+
* summarise "47 of 50000 used, 49953 left" reported it back as "49,953 of 50k
|
|
122
|
+
* used" — the opposite of the truth, on a line about somebody's bill.
|
|
123
|
+
*/
|
|
124
|
+
export declare function num(value: number | null | undefined): string;
|
|
125
|
+
/** Money, always with the unit, because a bare number here reads as anything. */
|
|
126
|
+
export declare function usd(value: number | null | undefined): string;
|