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 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 ten minutes. It looks at the 20 newest issues per repository in the time window.
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. |
@@ -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, 4, async ({ owner, name }) => {
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
- const resolved = await inPool(ranked, 5, async (dep) => {
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
- await this.write({ fetchedAt: this.now(), map });
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) {
@@ -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: 15_000,
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
- try {
30
- return ecosystem === "npm" ? await this.npm(pkg) : await this.pypi(pkg);
31
- }
32
- catch (err) {
33
- // An unknown package is a normal "no repo"; anything else should surface.
34
- if (axios.isAxiosError(err) && err.response?.status === 404)
35
- return null;
36
- throw err;
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) {
@@ -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
- if (!err.response)
19
- return 500 * 2 ** attempt; // network error / timeout
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
- if (delay === null || attempt >= MAX_RETRIES || delay > MAX_RATE_LIMIT_WAIT_MS)
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
- const perPage = Math.max(1, Math.min(PER_PAGE, max * 2));
129
- const raw = await this.paginate(`/repos/${owner}/${repo}/issues`, { state: "open", sort: "created", direction: "desc", per_page: perPage }, {
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 instanceof Error ? meta.stack ?? meta.message : meta);
20
+ sink(line, meta);
17
21
  }
18
22
  export const logger = {
19
23
  info: (msg, meta) => log("info", msg, meta),
@@ -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 = 72;
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
- if (axios.isAxiosError(err) && err.response) {
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 ?? 10 * 60_000;
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 = 72) {
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);
@@ -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 10 minutes.",
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 },
@@ -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", 600_000, 0, 86_400_000),
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.1",
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",