issuewatcher-mcp 0.2.1 → 0.2.2
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 +10 -1
- package/build/deps/discovery.js +18 -3
- package/build/deps/registry.js +22 -9
- package/build/github/api.js +10 -5
- package/build/logger.js +5 -1
- package/build/mcp/radar.js +5 -4
- package/build/mcp/server.js +1 -1
- package/build/mcp/settings.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,6 +29,14 @@ A classic token (starts with `ghp_`) with the `public_repo` scope also works. Ne
|
|
|
29
29
|
|
|
30
30
|
### 2. Add IssueWatcher to your AI app
|
|
31
31
|
|
|
32
|
+
**Recommended: install once so the server starts instantly.** The commands below use `npx`, which re-checks npm every time your app starts the server. On a slow connection or on Windows that can take 15 to 30 seconds, and some apps give up waiting (Claude Code stops at 30). Installing it once makes startup take under a second:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g issuewatcher-mcp
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Then, in any config below, use the command `issuewatcher-mcp` (with no arguments) instead of `npx -y issuewatcher-mcp`. For example, in Claude Code: `claude mcp add issuewatcher -e GITHUB_TOKEN=github_pat_xxxxxxxx -- issuewatcher-mcp`. To update later, run the same install command again. If you would rather not install it, the `npx` form below works and only the first start is slow.
|
|
39
|
+
|
|
32
40
|
Pick your app:
|
|
33
41
|
|
|
34
42
|
<details>
|
|
@@ -139,7 +147,7 @@ Restart your AI app, then try:
|
|
|
139
147
|
- *"Only show issues in nodejs/node and pydantic/pydantic."*
|
|
140
148
|
- *"What repos are you watching for me?"*
|
|
141
149
|
|
|
142
|
-
IssueWatcher starts loading your repositories as soon as your AI app starts it, so by the time you type your first question the answer is usually instant. If you ask within the first few seconds it may take up to about ten (longer on a slow connection); follow-up questions are instant for the next
|
|
150
|
+
IssueWatcher starts loading your repositories as soon as your AI app starts it, so by the time you type your first question the answer is usually instant. If you ask within the first few seconds it may take up to about ten (longer on a slow connection); follow-up questions are instant for the next thirty minutes. It looks at the 20 newest issues per repository in the time window.
|
|
143
151
|
|
|
144
152
|
## What the assistant can do (the tools)
|
|
145
153
|
|
|
@@ -198,6 +206,7 @@ LLM API keys (Gemini, OpenAI, Claude, Groq, ...) are only used by the *separate*
|
|
|
198
206
|
| Symptom | What to try |
|
|
199
207
|
| --- | --- |
|
|
200
208
|
| App shows "server disconnected" or no tools | Run the `--check` command from step 3 in a terminal; it prints the exact problem. Make sure Node.js 20 or newer is installed (`node --version`). |
|
|
209
|
+
| "Connection timed out" (for example after 30 s) when the app starts | Almost always `npx` being slow, not the server. Run `npm install -g issuewatcher-mcp` once and use the command `issuewatcher-mcp` instead of `npx -y issuewatcher-mcp`. Startup then takes under a second. |
|
|
201
210
|
| "needs a GitHub token" | The token is missing or not passed to the server. Re-check the `env` / `-e` setting and restart the app. |
|
|
202
211
|
| "GitHub rejected the token (401)" | The token expired or was copied wrongly. Create a new one. |
|
|
203
212
|
| "No repositories to watch" | You have not starred any repositories yet. Star a few and try again. |
|
package/build/deps/discovery.js
CHANGED
|
@@ -2,6 +2,13 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { logger } from "../logger.js";
|
|
4
4
|
import { MANIFEST_FILES, parseManifest } from "./manifests.js";
|
|
5
|
+
/**
|
|
6
|
+
* Maps built while some registry lookups failed (a registry outage, not "no such package"). They are
|
|
7
|
+
* usable, but must not be cached for the full day: the missing packages would stay missing.
|
|
8
|
+
*/
|
|
9
|
+
export const incompleteMaps = new WeakSet();
|
|
10
|
+
/** How long an incomplete map is kept before discovery is tried again. */
|
|
11
|
+
const INCOMPLETE_RETRY_MS = 15 * 60_000;
|
|
5
12
|
const DAY_MS = 86_400_000;
|
|
6
13
|
async function inPool(items, size, fn) {
|
|
7
14
|
const out = new Array(items.length);
|
|
@@ -34,7 +41,7 @@ export async function discoverDependencies(source, registry, opts, now = Date.no
|
|
|
34
41
|
.slice(0, opts.maxRepos);
|
|
35
42
|
}
|
|
36
43
|
const aggregated = new Map();
|
|
37
|
-
await inPool(repos,
|
|
44
|
+
await inPool(repos, 6, async ({ owner, name }) => {
|
|
38
45
|
for (const file of MANIFEST_FILES) {
|
|
39
46
|
let text;
|
|
40
47
|
try {
|
|
@@ -66,11 +73,13 @@ export async function discoverDependencies(source, registry, opts, now = Date.no
|
|
|
66
73
|
Number(b.kind === "runtime") - Number(a.kind === "runtime") ||
|
|
67
74
|
a.pkg.localeCompare(b.pkg))
|
|
68
75
|
.slice(0, opts.maxDependencies);
|
|
69
|
-
|
|
76
|
+
let lookupFailures = 0;
|
|
77
|
+
const resolved = await inPool(ranked, 12, async (dep) => {
|
|
70
78
|
try {
|
|
71
79
|
return await registry.resolve(dep.ecosystem, dep.pkg);
|
|
72
80
|
}
|
|
73
81
|
catch (err) {
|
|
82
|
+
lookupFailures++;
|
|
74
83
|
logger.warn(`Registry lookup failed for ${dep.ecosystem}:${dep.pkg}`, err);
|
|
75
84
|
return null;
|
|
76
85
|
}
|
|
@@ -83,6 +92,10 @@ export async function discoverDependencies(source, registry, opts, now = Date.no
|
|
|
83
92
|
(map[repo] ??= []).push({ ...dep, usedBy: [...dep.usedBy].sort() });
|
|
84
93
|
});
|
|
85
94
|
logger.info(`Dependency scan: ${repos.length} repo(s), ${aggregated.size} package(s), ${Object.keys(map).length} source repo(s) resolved`);
|
|
95
|
+
if (lookupFailures > 0) {
|
|
96
|
+
logger.warn(`${lookupFailures} registry lookup(s) failed; the list will be rebuilt in 15 minutes`);
|
|
97
|
+
incompleteMaps.add(map);
|
|
98
|
+
}
|
|
86
99
|
return map;
|
|
87
100
|
}
|
|
88
101
|
/**
|
|
@@ -107,7 +120,9 @@ export class CachedDiscovery {
|
|
|
107
120
|
return cached.map;
|
|
108
121
|
try {
|
|
109
122
|
const map = await this.run();
|
|
110
|
-
|
|
123
|
+
// An incomplete map is stored as if it were already almost expired, so the next start retries sooner.
|
|
124
|
+
const fetchedAt = incompleteMaps.has(map) ? this.now() - this.ttlMs + Math.min(INCOMPLETE_RETRY_MS, this.ttlMs) : this.now();
|
|
125
|
+
await this.write({ fetchedAt, map });
|
|
111
126
|
return map;
|
|
112
127
|
}
|
|
113
128
|
catch (err) {
|
package/build/deps/registry.js
CHANGED
|
@@ -13,11 +13,13 @@ export function githubRepoFromUrl(url) {
|
|
|
13
13
|
s.match(/^([\w.-]+)\/([\w.-]+)$/);
|
|
14
14
|
return m ? `${m[1]}/${m[2]}` : null;
|
|
15
15
|
}
|
|
16
|
+
const MAX_ATTEMPTS = 3;
|
|
17
|
+
const RETRY_DELAY_MS = 400;
|
|
16
18
|
export class HttpRegistry {
|
|
17
19
|
client;
|
|
18
20
|
urls;
|
|
19
21
|
constructor(client = axios.create({
|
|
20
|
-
timeout:
|
|
22
|
+
timeout: 10_000,
|
|
21
23
|
headers: { "User-Agent": "IssueWatcher" },
|
|
22
24
|
}),
|
|
23
25
|
/** Overridden in tests (a local stub) and for registry mirrors. */
|
|
@@ -26,14 +28,25 @@ export class HttpRegistry {
|
|
|
26
28
|
this.urls = urls;
|
|
27
29
|
}
|
|
28
30
|
async resolve(ecosystem, pkg) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
for (let attempt = 0;; attempt++) {
|
|
32
|
+
try {
|
|
33
|
+
return ecosystem === "npm" ? await this.npm(pkg) : await this.pypi(pkg);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
// An unknown package is a normal "no repo"; anything else should surface.
|
|
37
|
+
if (axios.isAxiosError(err) && err.response?.status === 404)
|
|
38
|
+
return null;
|
|
39
|
+
// A dropped connection or a registry hiccup is worth a second try; losing the package
|
|
40
|
+
// would silently remove its repo from the watch list.
|
|
41
|
+
const status = axios.isAxiosError(err) ? err.response?.status : undefined;
|
|
42
|
+
// A timeout is not retried: a server that did not answer in 10 s will not answer the second time
|
|
43
|
+
// either, and three tries would turn one hung registry into half a minute of waiting.
|
|
44
|
+
const timedOut = axios.isAxiosError(err) && (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT");
|
|
45
|
+
const transient = axios.isAxiosError(err) && !timedOut && (status === undefined || status < 400 || status >= 500);
|
|
46
|
+
if (!transient || attempt >= MAX_ATTEMPTS - 1)
|
|
47
|
+
throw err;
|
|
48
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS * (attempt + 1)));
|
|
49
|
+
}
|
|
37
50
|
}
|
|
38
51
|
}
|
|
39
52
|
async npm(pkg) {
|
package/build/github/api.js
CHANGED
|
@@ -15,8 +15,10 @@ function nextLink(res) {
|
|
|
15
15
|
/** Milliseconds to wait before retrying, or null if the failure is not retryable. */
|
|
16
16
|
function retryDelay(err, attempt) {
|
|
17
17
|
const status = err.response?.status;
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// A network error, a timeout, or a connection cut while the body was still arriving (axios then
|
|
19
|
+
// reports an error that carries the 200 it had already received).
|
|
20
|
+
if (!err.response || (status !== undefined && status < 400))
|
|
21
|
+
return 500 * 2 ** attempt;
|
|
20
22
|
const headers = err.response.headers;
|
|
21
23
|
const retryAfter = Number(headers["retry-after"]);
|
|
22
24
|
if (Number.isFinite(retryAfter) && retryAfter > 0)
|
|
@@ -56,7 +58,9 @@ export class GitHubAPI {
|
|
|
56
58
|
}
|
|
57
59
|
catch (err) {
|
|
58
60
|
const delay = axios.isAxiosError(err) ? retryDelay(err, attempt) : null;
|
|
59
|
-
|
|
61
|
+
// One retry after a timeout, not three: each try waits the full timeout.
|
|
62
|
+
const timedOut = axios.isAxiosError(err) && (err.code === "ECONNABORTED" || err.code === "ETIMEDOUT");
|
|
63
|
+
if (delay === null || attempt >= (timedOut ? 1 : MAX_RETRIES) || delay > MAX_RATE_LIMIT_WAIT_MS)
|
|
60
64
|
throw err;
|
|
61
65
|
logger.warn(`GitHub request failed (${url}), retrying in ${Math.round(delay / 1000)}s`);
|
|
62
66
|
await sleep(delay);
|
|
@@ -125,8 +129,9 @@ export class GitHubAPI {
|
|
|
125
129
|
*/
|
|
126
130
|
async getIssuesSince(owner, repo, since, max) {
|
|
127
131
|
const sinceMs = since.getTime();
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
// Full pages: the listing mixes in pull requests, which are dropped below, so a small page
|
|
133
|
+
// on a busy repo is mostly PRs and needs several round trips to reach the window's edge.
|
|
134
|
+
const raw = await this.paginate(`/repos/${owner}/${repo}/issues`, { state: "open", sort: "created", direction: "desc", per_page: PER_PAGE }, {
|
|
130
135
|
maxPages: 5,
|
|
131
136
|
stopAfter: (page) => page.some((i) => Date.parse(i.created_at ?? "") < sinceMs),
|
|
132
137
|
});
|
package/build/logger.js
CHANGED
|
@@ -12,8 +12,12 @@ function log(level, msg, meta) {
|
|
|
12
12
|
const sink = allToStderr ? console.error : level === "info" ? console.log : console[level];
|
|
13
13
|
if (meta === undefined)
|
|
14
14
|
sink(line);
|
|
15
|
+
// Stack traces are for real faults. A warning is an expected failure (a dropped connection, a
|
|
16
|
+
// missing file) and a one-line message says everything a user needs.
|
|
17
|
+
else if (meta instanceof Error)
|
|
18
|
+
sink(line, level === "error" ? meta.stack ?? meta.message : meta.message);
|
|
15
19
|
else
|
|
16
|
-
sink(line, meta
|
|
20
|
+
sink(line, meta);
|
|
17
21
|
}
|
|
18
22
|
export const logger = {
|
|
19
23
|
info: (msg, meta) => log("info", msg, meta),
|
package/build/mcp/radar.js
CHANGED
|
@@ -5,7 +5,7 @@ import { cleanLabel, cleanText, issueUrl, safeRepoName } from "./sanitize.js";
|
|
|
5
5
|
import { TtlCache } from "./ttl-cache.js";
|
|
6
6
|
const HOUR_MS = 3_600_000;
|
|
7
7
|
/** Every fetch reaches back at least this far, so a later question about a shorter window reuses it. */
|
|
8
|
-
const MIN_FETCH_HOURS =
|
|
8
|
+
const MIN_FETCH_HOURS = 168;
|
|
9
9
|
/** A failure the user can act on, with a message safe to show (no tokens, no raw API text). */
|
|
10
10
|
export class RadarError extends Error {
|
|
11
11
|
kind;
|
|
@@ -19,7 +19,8 @@ export class RadarError extends Error {
|
|
|
19
19
|
export function classifyError(err) {
|
|
20
20
|
if (err instanceof RadarError)
|
|
21
21
|
return err;
|
|
22
|
-
|
|
22
|
+
// A response that arrived with a success status but failed anyway was cut off mid-body: a network problem.
|
|
23
|
+
if (axios.isAxiosError(err) && err.response && err.response.status >= 400) {
|
|
23
24
|
const { status, headers } = err.response;
|
|
24
25
|
if (status === 401) {
|
|
25
26
|
return new RadarError("GitHub rejected the token (401). Create a new token and update GITHUB_TOKEN.", "auth");
|
|
@@ -67,7 +68,7 @@ export class Radar {
|
|
|
67
68
|
this.budgetMs = opts.budgetMs ?? 20_000;
|
|
68
69
|
this.perRepoMax = opts.perRepoMax ?? 20;
|
|
69
70
|
this.defaultMode = opts.defaultMode ?? "both";
|
|
70
|
-
const ttl = opts.cacheTtlMs ??
|
|
71
|
+
const ttl = opts.cacheTtlMs ?? 30 * 60_000;
|
|
71
72
|
this.repoIssues = new TtlCache(ttl, 500, this.now);
|
|
72
73
|
this.singleIssues = new TtlCache(ttl, 500, this.now);
|
|
73
74
|
this.targetsCache = new TtlCache(ttl, 1, this.now);
|
|
@@ -135,7 +136,7 @@ export class Radar {
|
|
|
135
136
|
* finds them ready instead of waiting. Never throws: if something fails, the real question will
|
|
136
137
|
* hit the same problem and report it properly.
|
|
137
138
|
*/
|
|
138
|
-
async warmUp(sinceHours =
|
|
139
|
+
async warmUp(sinceHours = MIN_FETCH_HOURS) {
|
|
139
140
|
try {
|
|
140
141
|
const targets = await this.targets();
|
|
141
142
|
const since = new Date(this.now() - sinceHours * HOUR_MS);
|
package/build/mcp/server.js
CHANGED
|
@@ -90,7 +90,7 @@ export function createIssueWatcherServer(deps) {
|
|
|
90
90
|
"their own projects depend on. Each issue has a 0-100 score and plain-language reasons. Use mode " +
|
|
91
91
|
"'contributor' for 'what could I pick up and fix?' (good first issues, unassigned, fresh, not already " +
|
|
92
92
|
"claimed) and 'consumer' for 'what could break my projects?'. Returns titles, labels, scores and links, " +
|
|
93
|
-
"not issue bodies. Results are cached for about
|
|
93
|
+
"not issue bodies. Results are cached for about 30 minutes.",
|
|
94
94
|
inputSchema: rankedInput,
|
|
95
95
|
outputSchema: rankedOut,
|
|
96
96
|
annotations: { title: "Get ranked issues", ...READ_ONLY },
|
package/build/mcp/settings.js
CHANGED
|
@@ -46,7 +46,7 @@ export function loadMcpSettings(env = process.env) {
|
|
|
46
46
|
cacheDir: path.resolve(text(env, "ISSUEWATCHER_CACHE_DIR") ?? path.join(os.homedir(), ".issuewatcher")),
|
|
47
47
|
concurrency: int("ISSUEWATCHER_CONCURRENCY", 8, 1, 32),
|
|
48
48
|
budgetMs: int("ISSUEWATCHER_BUDGET_MS", 20_000, 1_000, 120_000),
|
|
49
|
-
cacheTtlMs: int("ISSUEWATCHER_CACHE_TTL_MS",
|
|
49
|
+
cacheTtlMs: int("ISSUEWATCHER_CACHE_TTL_MS", 1_800_000, 0, 86_400_000),
|
|
50
50
|
prefetch: env.ISSUEWATCHER_PREFETCH?.trim().toLowerCase() !== "false",
|
|
51
51
|
};
|
|
52
52
|
return { settings, problems };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "issuewatcher-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "MCP server that ranks new GitHub issues for your AI assistant: what to pick up, and what may break your projects.",
|
|
5
5
|
"mcpName": "io.github.mac146/issuewatcher",
|
|
6
6
|
"license": "MIT",
|