@oss-scout/mcp 0.2.1 → 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John Costa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.js CHANGED
@@ -11,7 +11,7 @@ async function main() {
11
11
  process.exit(1);
12
12
  }
13
13
  const scout = await createScout({ githubToken: token });
14
- const server = new McpServer({ name: "@oss-scout/core", version: "0.1.0" }, { capabilities: { resources: {}, tools: {} } });
14
+ const server = new McpServer({ name: "oss-scout-mcp", version: "0.2.1" }, { capabilities: { resources: {}, tools: {} } });
15
15
  registerTools(server, scout);
16
16
  registerResources(server, scout);
17
17
  const transport = new StdioServerTransport();
@@ -35704,7 +35704,6 @@ var IssueScopeSchema = external_exports3.enum([
35704
35704
  ]);
35705
35705
  var SearchStrategySchema = external_exports3.enum([
35706
35706
  "merged",
35707
- "orgs",
35708
35707
  "starred",
35709
35708
  "broad",
35710
35709
  "maintained",
@@ -35712,7 +35711,6 @@ var SearchStrategySchema = external_exports3.enum([
35712
35711
  ]);
35713
35712
  var CONCRETE_STRATEGIES = [
35714
35713
  "merged",
35715
- "orgs",
35716
35714
  "starred",
35717
35715
  "broad",
35718
35716
  "maintained"
@@ -35785,6 +35783,13 @@ var TrackedIssueSchema = external_exports3.object({
35785
35783
  vetted: external_exports3.boolean(),
35786
35784
  vettingResult: IssueVettingResultSchema.optional()
35787
35785
  });
35786
+ var SkippedIssueSchema = external_exports3.object({
35787
+ url: external_exports3.string(),
35788
+ repo: external_exports3.string(),
35789
+ number: external_exports3.number(),
35790
+ title: external_exports3.string(),
35791
+ skippedAt: external_exports3.string()
35792
+ });
35788
35793
  var SavedCandidateSchema = external_exports3.object({
35789
35794
  issueUrl: external_exports3.string(),
35790
35795
  repo: external_exports3.string(),
@@ -35801,20 +35806,22 @@ var SavedCandidateSchema = external_exports3.object({
35801
35806
  var PersistenceModeSchema = external_exports3.enum(["local", "gist"]);
35802
35807
  var ScoutPreferencesSchema = external_exports3.object({
35803
35808
  githubUsername: external_exports3.string().default(""),
35804
- languages: external_exports3.array(external_exports3.string()).default(["typescript", "javascript"]),
35809
+ languages: external_exports3.array(external_exports3.string()).default(["any"]),
35805
35810
  labels: external_exports3.array(external_exports3.string()).default(["good first issue", "help wanted"]),
35806
35811
  scope: external_exports3.array(IssueScopeSchema).optional(),
35807
35812
  excludeRepos: external_exports3.array(external_exports3.string()).default([]),
35808
35813
  excludeOrgs: external_exports3.array(external_exports3.string()).default([]),
35809
35814
  aiPolicyBlocklist: external_exports3.array(external_exports3.string()).default(["matplotlib/matplotlib"]),
35810
- preferredOrgs: external_exports3.array(external_exports3.string()).default([]),
35811
35815
  projectCategories: external_exports3.array(ProjectCategorySchema).default([]),
35812
35816
  minStars: external_exports3.number().default(50),
35813
35817
  maxIssueAgeDays: external_exports3.number().default(90),
35814
35818
  includeDocIssues: external_exports3.boolean().default(true),
35815
35819
  minRepoScoreThreshold: external_exports3.number().default(4),
35820
+ interPhaseDelayMs: external_exports3.number().min(0).max(12e4).default(3e4),
35816
35821
  persistence: PersistenceModeSchema.default("local"),
35817
- defaultStrategy: external_exports3.array(SearchStrategySchema).optional()
35822
+ defaultStrategy: external_exports3.array(SearchStrategySchema).optional(),
35823
+ broadPhaseDelayMs: external_exports3.number().min(0).max(3e5).default(9e4),
35824
+ skipBroadWhenSufficientResults: external_exports3.number().int().min(0).max(100).default(15)
35818
35825
  });
35819
35826
  var ScoutStateSchema = external_exports3.object({
35820
35827
  version: external_exports3.literal(1),
@@ -35825,6 +35832,7 @@ var ScoutStateSchema = external_exports3.object({
35825
35832
  mergedPRs: external_exports3.array(StoredMergedPRSchema).default([]),
35826
35833
  closedPRs: external_exports3.array(StoredClosedPRSchema).default([]),
35827
35834
  savedResults: external_exports3.array(SavedCandidateSchema).default([]),
35835
+ skippedIssues: external_exports3.array(SkippedIssueSchema).default([]),
35828
35836
  lastSearchAt: external_exports3.string().optional(),
35829
35837
  lastRunAt: external_exports3.string().default(() => (/* @__PURE__ */ new Date()).toISOString()),
35830
35838
  gistId: external_exports3.string().optional()
@@ -36760,12 +36768,9 @@ var IssueVetter = class {
36760
36768
  matchesPreferredCategory: matchesCategory
36761
36769
  });
36762
36770
  const starredRepos = this.stateReader.getStarredRepos();
36763
- const preferredOrgs = this.stateReader.getPreferredOrgs();
36764
36771
  let searchPriority = "normal";
36765
36772
  if (effectiveMergedCount > 0) {
36766
36773
  searchPriority = "merged_pr";
36767
- } else if (preferredOrgs.some((o) => o.toLowerCase() === orgName?.toLowerCase())) {
36768
- searchPriority = "preferred_org";
36769
36774
  } else if (starredRepos.includes(repoFullName)) {
36770
36775
  searchPriority = "starred";
36771
36776
  }
@@ -36831,7 +36836,6 @@ var IssueVetter = class {
36831
36836
  var MODULE8 = "search-phases";
36832
36837
  var GITHUB_MAX_BOOLEAN_OPS = 5;
36833
36838
  var INTER_QUERY_DELAY_MS = 2e3;
36834
- var BATCH_SIZE = 3;
36835
36839
  function chunkLabels(labels, reservedOps = 0) {
36836
36840
  const maxPerChunk = GITHUB_MAX_BOOLEAN_OPS - reservedOps + 1;
36837
36841
  if (maxPerChunk < 1) {
@@ -36877,26 +36881,138 @@ function interleaveArrays(arrays) {
36877
36881
  }
36878
36882
  return result;
36879
36883
  }
36880
- function batchRepos(repos, batchSize) {
36881
- const batches = [];
36882
- for (let i = 0; i < repos.length; i += batchSize) {
36883
- batches.push(repos.slice(i, i + batchSize));
36884
- }
36885
- return batches;
36886
- }
36887
36884
  var SEARCH_CACHE_TTL_MS = 15 * 60 * 1e3;
36888
36885
  async function cachedSearchIssues(octokit, params) {
36889
36886
  const cacheKey = `search:${params.q}:${params.sort}:${params.order}:${params.per_page}`;
36890
- return cachedTimeBased(getHttpCache(), cacheKey, SEARCH_CACHE_TTL_MS, async () => {
36891
- const tracker = getSearchBudgetTracker();
36892
- await tracker.waitForBudget();
36887
+ const cache = getHttpCache();
36888
+ const cached2 = cache.getIfFresh(cacheKey, SEARCH_CACHE_TTL_MS);
36889
+ if (cached2) {
36890
+ debug(MODULE8, `Search cache hit for query`);
36891
+ return cached2;
36892
+ }
36893
+ const tracker = getSearchBudgetTracker();
36894
+ await tracker.waitForBudget();
36895
+ let data;
36896
+ try {
36897
+ const response = await octokit.search.issuesAndPullRequests(params);
36898
+ data = response.data;
36899
+ } finally {
36900
+ tracker.recordCall();
36901
+ }
36902
+ if (data.items.length > 0) {
36903
+ cache.set(cacheKey, "", data);
36904
+ } else {
36905
+ debug(MODULE8, `Skipping cache for empty search result (possible rate limit artifact)`);
36906
+ }
36907
+ return data;
36908
+ }
36909
+ async function fetchIssuesFromMaintainedRepos(octokit, repos, minStars, maxResults) {
36910
+ const items = [];
36911
+ for (const repoFullName of repos) {
36912
+ if (items.length >= maxResults * 3)
36913
+ break;
36914
+ const [owner, repo] = repoFullName.split("/");
36915
+ if (!owner || !repo)
36916
+ continue;
36893
36917
  try {
36894
- const { data } = await octokit.search.issuesAndPullRequests(params);
36895
- return data;
36896
- } finally {
36897
- tracker.recordCall();
36918
+ const { data: repoData } = await octokit.repos.get({ owner, repo });
36919
+ if (!repoData.pushed_at)
36920
+ continue;
36921
+ const pushedAt = new Date(repoData.pushed_at);
36922
+ const thirtyDaysAgo = /* @__PURE__ */ new Date();
36923
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
36924
+ if (pushedAt < thirtyDaysAgo)
36925
+ continue;
36926
+ if ((repoData.stargazers_count ?? 0) < minStars)
36927
+ continue;
36928
+ if (repoData.archived)
36929
+ continue;
36930
+ const { data: issues } = await octokit.issues.listForRepo({
36931
+ owner,
36932
+ repo,
36933
+ state: "open",
36934
+ sort: "created",
36935
+ direction: "desc",
36936
+ per_page: 5
36937
+ });
36938
+ const realIssues = issues.filter((i) => !i.pull_request && !i.assignee);
36939
+ for (const issue2 of realIssues) {
36940
+ items.push({
36941
+ html_url: issue2.html_url,
36942
+ repository_url: `https://api.github.com/repos/${repoFullName}`,
36943
+ updated_at: issue2.updated_at ?? "",
36944
+ title: issue2.title,
36945
+ labels: issue2.labels
36946
+ });
36947
+ }
36948
+ await sleep(INTER_QUERY_DELAY_MS);
36949
+ } catch (error48) {
36950
+ if (getHttpStatusCode(error48) === 401)
36951
+ throw error48;
36952
+ if (isRateLimitError(error48)) {
36953
+ warn(MODULE8, `Rate limit hit fetching issues from ${repoFullName}:`, errorMessage(error48));
36954
+ break;
36955
+ }
36956
+ warn(MODULE8, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
36898
36957
  }
36899
- });
36958
+ }
36959
+ return items;
36960
+ }
36961
+ async function fetchIssuesFromKnownRepos(octokit, vetter, repos, labels, maxResults, priority, filterFn) {
36962
+ const candidates = [];
36963
+ let failedRepos = 0;
36964
+ let rateLimitFailures = 0;
36965
+ for (let i = 0; i < repos.length; i++) {
36966
+ if (candidates.length >= maxResults)
36967
+ break;
36968
+ if (i > 0)
36969
+ await sleep(INTER_QUERY_DELAY_MS);
36970
+ const repoFullName = repos[i];
36971
+ const [owner, repo] = repoFullName.split("/");
36972
+ try {
36973
+ const response = await octokit.issues.listForRepo({
36974
+ owner,
36975
+ repo,
36976
+ state: "open",
36977
+ sort: "created",
36978
+ direction: "desc",
36979
+ per_page: 5,
36980
+ ...labels.length > 0 ? { labels: labels.join(",") } : {}
36981
+ });
36982
+ const issuesOnly = response.data.filter((item) => !("pull_request" in item) && !item.assignee);
36983
+ const mapped = issuesOnly.map((issue2) => ({
36984
+ html_url: issue2.html_url,
36985
+ repository_url: `https://api.github.com/repos/${repoFullName}`,
36986
+ updated_at: issue2.updated_at ?? "",
36987
+ title: issue2.title,
36988
+ labels: issue2.labels
36989
+ }));
36990
+ if (mapped.length > 0) {
36991
+ const filtered = filterFn(mapped);
36992
+ if (filtered.length > 0) {
36993
+ const remainingNeeded = maxResults - candidates.length;
36994
+ const { candidates: vetted, rateLimitHit: vetRateLimitHit } = await vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((item) => item.html_url), remainingNeeded, priority);
36995
+ candidates.push(...vetted);
36996
+ if (vetRateLimitHit)
36997
+ rateLimitFailures++;
36998
+ }
36999
+ }
37000
+ } catch (error48) {
37001
+ if (getHttpStatusCode(error48) === 401)
37002
+ throw error48;
37003
+ failedRepos++;
37004
+ if (isRateLimitError(error48)) {
37005
+ rateLimitFailures++;
37006
+ }
37007
+ warn(MODULE8, `Error fetching issues from ${repoFullName}:`, errorMessage(error48));
37008
+ }
37009
+ }
37010
+ const allReposFailed = failedRepos === repos.length && repos.length > 0;
37011
+ const rateLimitHit = rateLimitFailures > 0;
37012
+ if (allReposFailed) {
37013
+ warn(MODULE8, `All ${repos.length} repo(s) failed for ${priority} phase. This may indicate a systemic issue (rate limit, auth, network).`);
37014
+ }
37015
+ return { candidates, allReposFailed, rateLimitHit };
36900
37016
  }
36901
37017
  async function searchWithChunkedLabels(octokit, labels, reservedOps, buildQuery, perPage) {
36902
37018
  const labelChunks = chunkLabels(labels, reservedOps);
@@ -36952,50 +37068,9 @@ async function filterVetAndScore(vetter, items, filterIssues, excludedRepoSets,
36952
37068
  }
36953
37069
  return { candidates: starFiltered, allVetFailed, rateLimitHit };
36954
37070
  }
36955
- async function searchInRepos(octokit, vetter, repos, baseQualifiers, labels, maxResults, priority, filterFn) {
36956
- const candidates = [];
36957
- const batches = batchRepos(repos, BATCH_SIZE);
36958
- let failedBatches = 0;
36959
- let rateLimitFailures = 0;
36960
- for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
36961
- const batch = batches[batchIdx];
36962
- if (candidates.length >= maxResults)
36963
- break;
36964
- if (batchIdx > 0)
36965
- await sleep(INTER_QUERY_DELAY_MS);
36966
- try {
36967
- const repoFilter = batch.map((r) => `repo:${r}`).join(" OR ");
36968
- const repoOps = batch.length - 1;
36969
- const perPage = Math.min(30, (maxResults - candidates.length) * 3);
36970
- const allItems = await searchWithChunkedLabels(octokit, labels, repoOps, (labelQ) => `${baseQualifiers} ${labelQ} (${repoFilter})`.replace(/ +/g, " ").trim(), perPage);
36971
- if (allItems.length > 0) {
36972
- const filtered = filterFn(allItems);
36973
- const remainingNeeded = maxResults - candidates.length;
36974
- const { candidates: vetted, rateLimitHit: vetRateLimitHit } = await vetter.vetIssuesParallel(filtered.slice(0, remainingNeeded * 2).map((i) => i.html_url), remainingNeeded, priority);
36975
- candidates.push(...vetted);
36976
- if (vetRateLimitHit)
36977
- rateLimitFailures++;
36978
- }
36979
- } catch (error48) {
36980
- failedBatches++;
36981
- if (isRateLimitError(error48)) {
36982
- rateLimitFailures++;
36983
- }
36984
- const batchReposStr = batch.join(", ");
36985
- warn(MODULE8, `Error searching issues in batch [${batchReposStr}]:`, errorMessage(error48));
36986
- }
36987
- }
36988
- const allBatchesFailed = failedBatches === batches.length && batches.length > 0;
36989
- const rateLimitHit = rateLimitFailures > 0;
36990
- if (allBatchesFailed) {
36991
- warn(MODULE8, `All ${batches.length} batch(es) failed for ${priority} phase. This may indicate a systemic issue (rate limit, auth, network).`);
36992
- }
36993
- return { candidates, allBatchesFailed, rateLimitHit };
36994
- }
36995
37071
 
36996
37072
  // ../core/dist/core/issue-discovery.js
36997
37073
  var MODULE9 = "issue-discovery";
36998
- var INTER_PHASE_DELAY_MS = 2e3;
36999
37074
  var LOW_BUDGET_THRESHOLD = 20;
37000
37075
  var CRITICAL_BUDGET_THRESHOLD = 10;
37001
37076
  function buildIssueFilter(config2) {
@@ -37015,6 +37090,8 @@ function buildIssueFilter(config2) {
37015
37090
  return false;
37016
37091
  if (config2.lowScoringRepos.has(repoFullName))
37017
37092
  return false;
37093
+ if (config2.skippedUrls.has(item.html_url))
37094
+ return false;
37018
37095
  const updatedAt = new Date(item.updated_at);
37019
37096
  const ageDays = daysBetween(updatedAt, config2.now);
37020
37097
  if (ageDays > config2.maxAgeDays)
@@ -37025,56 +37102,25 @@ function buildIssueFilter(config2) {
37025
37102
  });
37026
37103
  };
37027
37104
  }
37028
- async function runPhase0(octokit, vetter, repos, baseQualifiers, maxResults, filterIssues) {
37105
+ async function runPhase0(octokit, vetter, repos, maxResults, filterIssues) {
37029
37106
  info(MODULE9, `Phase 0: Searching issues in ${repos.length} merged-PR repos (no label filter)...`);
37030
- const { candidates, allBatchesFailed, rateLimitHit } = await searchInRepos(octokit, vetter, repos, baseQualifiers, [], maxResults, "merged_pr", filterIssues);
37107
+ const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, repos, [], maxResults, "merged_pr", filterIssues);
37031
37108
  info(MODULE9, `Found ${candidates.length} candidates from merged-PR repos`);
37032
37109
  return {
37033
37110
  candidates,
37034
- error: allBatchesFailed ? "All merged-PR repo batches failed" : null,
37111
+ error: allReposFailed ? "All merged-PR repo fetches failed" : null,
37035
37112
  rateLimitHit
37036
37113
  };
37037
37114
  }
37038
- async function runPhase05(octokit, vetter, orgsToSearch, baseQualifiers, labels, maxResults, phase0RepoSet, filterIssues) {
37039
- info(MODULE9, `Phase 0.5: Searching issues in ${orgsToSearch.length} preferred org(s)...`);
37040
- const orgRepoFilter = orgsToSearch.map((org) => `org:${org}`).join(" OR ");
37041
- const orgOps = orgsToSearch.length - 1;
37042
- try {
37043
- const allItems = await searchWithChunkedLabels(octokit, labels, orgOps, (labelQ) => `${baseQualifiers} ${labelQ} (${orgRepoFilter})`.replace(/ +/g, " ").trim(), maxResults * 3);
37044
- if (allItems.length === 0) {
37045
- return { candidates: [], error: null, rateLimitHit: false };
37046
- }
37047
- const filtered = filterIssues(allItems).filter((item) => {
37048
- const repoFullName = extractRepoFromUrl(item.repository_url);
37049
- if (!repoFullName)
37050
- return false;
37051
- return !phase0RepoSet.has(repoFullName);
37052
- });
37053
- const { candidates, allFailed: allVetFailed, rateLimitHit } = await vetter.vetIssuesParallel(filtered.slice(0, maxResults * 2).map((i) => i.html_url), maxResults, "preferred_org");
37054
- info(MODULE9, `Found ${candidates.length} candidates from preferred orgs`);
37055
- return {
37056
- candidates,
37057
- error: allVetFailed ? "All preferred org issue vetting failed" : null,
37058
- rateLimitHit
37059
- };
37060
- } catch (error48) {
37061
- const errMsg = errorMessage(error48);
37062
- warn(MODULE9, `Error searching preferred orgs: ${errMsg}`);
37063
- return {
37064
- candidates: [],
37065
- error: errMsg,
37066
- rateLimitHit: isRateLimitError(error48)
37067
- };
37068
- }
37069
- }
37070
- async function runPhase1(octokit, vetter, repos, baseQualifiers, labels, maxResults, filterIssues) {
37115
+ async function runPhase1(octokit, vetter, repos, labels, maxResults, filterIssues) {
37071
37116
  info(MODULE9, `Phase 1: Searching issues in ${repos.length} starred repos...`);
37072
37117
  const phase1Labels = labels.slice(0, 3);
37073
- const { candidates, allBatchesFailed, rateLimitHit } = await searchInRepos(octokit, vetter, repos.slice(0, 10), baseQualifiers, phase1Labels, maxResults, "starred", filterIssues);
37118
+ const reposToSearch = repos.slice(0, 10);
37119
+ const { candidates, allReposFailed, rateLimitHit } = await fetchIssuesFromKnownRepos(octokit, vetter, reposToSearch, phase1Labels, maxResults, "starred", filterIssues);
37074
37120
  info(MODULE9, `Found ${candidates.length} candidates from starred repos`);
37075
37121
  return {
37076
37122
  candidates,
37077
- error: allBatchesFailed ? "All starred repo batches failed" : null,
37123
+ error: allReposFailed ? "All starred repo fetches failed" : null,
37078
37124
  rateLimitHit
37079
37125
  };
37080
37126
  }
@@ -37140,8 +37186,32 @@ async function runPhase2(octokit, vetter, scopes, labels, configLabels, baseQual
37140
37186
  rateLimitHit
37141
37187
  };
37142
37188
  }
37143
- async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, existingCandidates, filterIssues) {
37189
+ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories, maxResults, phase0RepoSet, starredRepoSet, starredRepos, existingCandidates, filterIssues) {
37144
37190
  info(MODULE9, "Phase 3: Searching actively maintained repos...");
37191
+ const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
37192
+ const eligibleStarred = starredRepos.filter((r) => !phase0RepoSet.has(r) && !seenRepos.has(r));
37193
+ if (eligibleStarred.length > 0) {
37194
+ info(MODULE9, `Phase 3: Checking ${eligibleStarred.length} starred repos via REST API...`);
37195
+ const restItems = await fetchIssuesFromMaintainedRepos(octokit, eligibleStarred.slice(0, 15), minStars, maxResults);
37196
+ if (restItems.length > 0) {
37197
+ try {
37198
+ const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, restItems, filterIssues, [phase0RepoSet, seenRepos], maxResults, minStars, "Phase 3 (REST)");
37199
+ if (candidates.length > 0) {
37200
+ info(MODULE9, `Found ${candidates.length} candidates from maintained-repo REST search`);
37201
+ return {
37202
+ candidates,
37203
+ error: allVetFailed ? "all vetting failed" : null,
37204
+ rateLimitHit: vetRateLimitHit
37205
+ };
37206
+ }
37207
+ } catch (error48) {
37208
+ if (getHttpStatusCode(error48) === 401)
37209
+ throw error48;
37210
+ warn(MODULE9, `Phase 3 REST vetting failed, falling back to Search API:`, errorMessage(error48));
37211
+ }
37212
+ }
37213
+ }
37214
+ info(MODULE9, "Phase 3: Falling back to Search API...");
37145
37215
  const thirtyDaysAgo = /* @__PURE__ */ new Date();
37146
37216
  thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
37147
37217
  const pushedSince = thirtyDaysAgo.toISOString().split("T")[0];
@@ -37156,7 +37226,6 @@ async function runPhase3(octokit, vetter, langQuery, minStars, projectCategories
37156
37226
  per_page: maxResults * 3
37157
37227
  });
37158
37228
  info(MODULE9, `Found ${data.total_count} issues in maintained-repo search, processing top ${data.items.length}...`);
37159
- const seenRepos = new Set(existingCandidates.map((c) => c.issue.repo));
37160
37229
  const { candidates, allVetFailed, rateLimitHit: vetRateLimitHit } = await filterVetAndScore(vetter, data.items, filterIssues, [phase0RepoSet, starredRepoSet, seenRepos], maxResults, minStars, "Phase 3");
37161
37230
  info(MODULE9, `Found ${candidates.length} candidates from maintained-repo search`);
37162
37231
  return {
@@ -37203,8 +37272,8 @@ var IssueDiscovery = class {
37203
37272
  }
37204
37273
  /**
37205
37274
  * Search for issues matching our criteria.
37206
- * Searches in priority order: merged-PR repos first (no label filter), then preferred
37207
- * organizations, then starred repos, then general search, then actively maintained repos.
37275
+ * Searches in priority order: merged-PR repos first (no label filter), then starred
37276
+ * repos, then general search, then actively maintained repos.
37208
37277
  * Filters out issues from low-scoring and excluded repos.
37209
37278
  *
37210
37279
  * @param options - Search configuration
@@ -37232,6 +37301,7 @@ var IssueDiscovery = class {
37232
37301
  const labels = options.labels || (scopes ? buildEffectiveLabels(scopes, config2.labels) : config2.labels);
37233
37302
  const maxResults = options.maxResults || 10;
37234
37303
  const minStars = config2.minStars ?? 50;
37304
+ const interPhaseDelay = config2.interPhaseDelayMs ?? 3e4;
37235
37305
  const ALL_STRATEGIES = CONCRETE_STRATEGIES;
37236
37306
  const rawStrategies = options.strategies ?? config2.defaultStrategy ?? ["all"];
37237
37307
  const enabledStrategies = new Set(rawStrategies.includes("all") ? ALL_STRATEGIES : rawStrategies);
@@ -37262,6 +37332,10 @@ var IssueDiscovery = class {
37262
37332
  tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 6e4).toISOString());
37263
37333
  warn(MODULE9, "Could not check rate limit \u2014 using conservative budget, skipping heavy phases:", errorMessage(error48));
37264
37334
  }
37335
+ if (searchBudget <= 0) {
37336
+ this.rateLimitWarning = "GitHub search API quota exhausted. Try again after the rate limit resets.";
37337
+ return { candidates: [], strategiesUsed: [] };
37338
+ }
37265
37339
  const mergedPRRepos = this.stateReader.getReposWithMergedPRs();
37266
37340
  const starredRepos = this.getStarredRepos();
37267
37341
  const starredRepoSet = new Set(starredRepos);
@@ -37278,6 +37352,7 @@ var IssueDiscovery = class {
37278
37352
  excludeOrgs: new Set((config2.excludeOrgs ?? []).map((o) => o.toLowerCase())),
37279
37353
  aiBlocklisted,
37280
37354
  lowScoringRepos,
37355
+ skippedUrls: options.skippedUrls ?? /* @__PURE__ */ new Set(),
37281
37356
  maxAgeDays: config2.maxIssueAgeDays || 90,
37282
37357
  now: /* @__PURE__ */ new Date(),
37283
37358
  includeDocIssues: config2.includeDocIssues ?? true
@@ -37287,7 +37362,7 @@ var IssueDiscovery = class {
37287
37362
  if (phase0Repos.length > 0 && enabledStrategies.has("merged")) {
37288
37363
  const remaining = maxResults - allCandidates.length;
37289
37364
  if (remaining > 0) {
37290
- const result = await runPhase0(this.octokit, this.vetter, phase0Repos, baseQualifiers, remaining, filterIssues);
37365
+ const result = await runPhase0(this.octokit, this.vetter, phase0Repos, remaining, filterIssues);
37291
37366
  allCandidates.push(...result.candidates);
37292
37367
  phaseErrors["0"] = result.error;
37293
37368
  if (result.rateLimitHit)
@@ -37295,29 +37370,16 @@ var IssueDiscovery = class {
37295
37370
  }
37296
37371
  strategiesUsed.push("merged");
37297
37372
  }
37298
- const preferredOrgs = config2.preferredOrgs ?? [];
37299
- if (allCandidates.length < maxResults && preferredOrgs.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD && enabledStrategies.has("orgs")) {
37300
- if (phase0Repos.length > 0)
37301
- await sleep(INTER_PHASE_DELAY_MS);
37302
- const phase0Orgs = new Set(phase0Repos.map((r) => r.split("/")[0]?.toLowerCase()));
37303
- const orgsToSearch = preferredOrgs.filter((org) => !phase0Orgs.has(org.toLowerCase())).slice(0, 5);
37304
- if (orgsToSearch.length > 0) {
37305
- const remaining = maxResults - allCandidates.length;
37306
- const result = await runPhase05(this.octokit, this.vetter, orgsToSearch, baseQualifiers, labels, remaining, phase0RepoSet, filterIssues);
37307
- allCandidates.push(...result.candidates);
37308
- phaseErrors["0.5"] = result.error;
37309
- if (result.rateLimitHit)
37310
- rateLimitHitDuringSearch = true;
37311
- }
37312
- strategiesUsed.push("orgs");
37313
- }
37314
37373
  if (allCandidates.length < maxResults && starredRepos.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD && enabledStrategies.has("starred")) {
37315
- await sleep(INTER_PHASE_DELAY_MS);
37374
+ if (interPhaseDelay > 0) {
37375
+ info(MODULE9, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
37376
+ await sleep(interPhaseDelay);
37377
+ }
37316
37378
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
37317
37379
  if (reposToSearch.length > 0) {
37318
37380
  const remaining = maxResults - allCandidates.length;
37319
37381
  if (remaining > 0) {
37320
- const result = await runPhase1(this.octokit, this.vetter, reposToSearch, baseQualifiers, labels, remaining, filterIssues);
37382
+ const result = await runPhase1(this.octokit, this.vetter, reposToSearch, labels, remaining, filterIssues);
37321
37383
  allCandidates.push(...result.candidates);
37322
37384
  phaseErrors["1"] = result.error;
37323
37385
  if (result.rateLimitHit)
@@ -37326,20 +37388,38 @@ var IssueDiscovery = class {
37326
37388
  }
37327
37389
  strategiesUsed.push("starred");
37328
37390
  }
37391
+ const broadDelay = config2.broadPhaseDelayMs ?? 9e4;
37392
+ const skipThreshold = config2.skipBroadWhenSufficientResults ?? 15;
37329
37393
  if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD && enabledStrategies.has("broad")) {
37330
- await sleep(INTER_PHASE_DELAY_MS);
37331
- const remaining = maxResults - allCandidates.length;
37332
- const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config2.labels, baseQualifiers, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
37333
- allCandidates.push(...result.candidates);
37334
- phaseErrors["2"] = result.error;
37335
- if (result.rateLimitHit)
37336
- rateLimitHitDuringSearch = true;
37394
+ if (skipThreshold > 0 && allCandidates.length >= skipThreshold) {
37395
+ info(MODULE9, `Skipping broad search: already found ${allCandidates.length} candidates (threshold: ${skipThreshold})`);
37396
+ } else {
37397
+ if (interPhaseDelay > 0) {
37398
+ info(MODULE9, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
37399
+ await sleep(interPhaseDelay);
37400
+ }
37401
+ if (allCandidates.length > 0 && broadDelay > 0) {
37402
+ info(MODULE9, `Waiting ${(broadDelay / 1e3).toFixed(0)}s for rate limit cooldown before broad search...`);
37403
+ await sleep(broadDelay);
37404
+ } else if (allCandidates.length === 0) {
37405
+ info(MODULE9, `Skipping broad phase delay: no results from previous phases, proceeding immediately`);
37406
+ }
37407
+ const remaining = maxResults - allCandidates.length;
37408
+ const result = await runPhase2(this.octokit, this.vetter, scopes, labels, config2.labels, baseQualifiers, remaining, minStars, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
37409
+ allCandidates.push(...result.candidates);
37410
+ phaseErrors["2"] = result.error;
37411
+ if (result.rateLimitHit)
37412
+ rateLimitHitDuringSearch = true;
37413
+ }
37337
37414
  strategiesUsed.push("broad");
37338
37415
  }
37339
37416
  if (allCandidates.length < maxResults && searchBudget >= LOW_BUDGET_THRESHOLD && enabledStrategies.has("maintained")) {
37340
- await sleep(INTER_PHASE_DELAY_MS);
37417
+ if (interPhaseDelay > 0) {
37418
+ info(MODULE9, `Waiting ${(interPhaseDelay / 1e3).toFixed(0)}s between phases for rate limit management...`);
37419
+ await sleep(interPhaseDelay);
37420
+ }
37341
37421
  const remaining = maxResults - allCandidates.length;
37342
- const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config2.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, allCandidates, filterIssues);
37422
+ const result = await runPhase3(this.octokit, this.vetter, langQuery, minStars, config2.projectCategories ?? [], remaining, phase0RepoSet, starredRepoSet, starredRepos, allCandidates, filterIssues);
37343
37423
  allCandidates.push(...result.candidates);
37344
37424
  phaseErrors["3"] = result.error;
37345
37425
  if (result.rateLimitHit)
@@ -37356,7 +37436,6 @@ var IssueDiscovery = class {
37356
37436
  if (allCandidates.length === 0) {
37357
37437
  const errorDetails = [
37358
37438
  phaseErrors["0"] ? `Phase 0 (merged-PR repos): ${phaseErrors["0"]}` : null,
37359
- phaseErrors["0.5"] ? `Phase 0.5 (preferred orgs): ${phaseErrors["0.5"]}` : null,
37360
37439
  phaseErrors["1"] ? `Phase 1 (starred repos): ${phaseErrors["1"]}` : null,
37361
37440
  phaseErrors["2"] ? `Phase 2 (general): ${phaseErrors["2"]}` : null,
37362
37441
  phaseErrors["3"] ? `Phase 3 (maintained repos): ${phaseErrors["3"]}` : null
@@ -37374,9 +37453,8 @@ var IssueDiscovery = class {
37374
37453
  allCandidates.sort((a, b) => {
37375
37454
  const priorityOrder = {
37376
37455
  merged_pr: 0,
37377
- preferred_org: 1,
37378
- starred: 2,
37379
- normal: 3
37456
+ starred: 1,
37457
+ normal: 2
37380
37458
  };
37381
37459
  const priorityDiff = priorityOrder[a.searchPriority] - priorityOrder[b.searchPriority];
37382
37460
  if (priorityDiff !== 0)
@@ -37462,11 +37540,16 @@ var GistStateStore = class {
37462
37540
  warn(MODULE10, "No gist ID \u2014 cannot push");
37463
37541
  return false;
37464
37542
  }
37543
+ const json2 = JSON.stringify(state, null, 2);
37544
+ if (json2.length > 9e5) {
37545
+ warn(MODULE10, `State too large for gist (${Math.round(json2.length / 1024)}KB). Consider clearing old results with 'oss-scout results clear'.`);
37546
+ return false;
37547
+ }
37465
37548
  try {
37466
37549
  await this.octokit.gists.update({
37467
37550
  gist_id: this.gistId,
37468
37551
  files: {
37469
- [GIST_FILENAME]: { content: JSON.stringify(state, null, 2) }
37552
+ [GIST_FILENAME]: { content: json2 }
37470
37553
  }
37471
37554
  });
37472
37555
  debug(MODULE10, "State pushed to gist");
@@ -37639,6 +37722,7 @@ function mergeStates(local, remote) {
37639
37722
  mergedPRs: unionByUrl(local.mergedPRs, remote.mergedPRs),
37640
37723
  closedPRs: unionByUrl(local.closedPRs, remote.closedPRs),
37641
37724
  savedResults: mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []),
37725
+ skippedIssues: mergeSkippedIssues(local.skippedIssues ?? [], remote.skippedIssues ?? []),
37642
37726
  lastSearchAt: pickFresherTimestamp(local.lastSearchAt, remote.lastSearchAt),
37643
37727
  lastRunAt: pickFresherTimestamp(local.lastRunAt, remote.lastRunAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
37644
37728
  gistId: remote.gistId ?? local.gistId
@@ -37689,6 +37773,18 @@ function mergeSavedResults(local, remote) {
37689
37773
  }
37690
37774
  return [...merged.values()];
37691
37775
  }
37776
+ function mergeSkippedIssues(local, remote) {
37777
+ const merged = /* @__PURE__ */ new Map();
37778
+ for (const item of local)
37779
+ merged.set(item.url, item);
37780
+ for (const item of remote) {
37781
+ const existing = merged.get(item.url);
37782
+ if (!existing || item.skippedAt > existing.skippedAt) {
37783
+ merged.set(item.url, item);
37784
+ }
37785
+ }
37786
+ return [...merged.values()];
37787
+ }
37692
37788
  function pickFresherTimestamp(a, b) {
37693
37789
  if (!a)
37694
37790
  return b;
@@ -37719,7 +37815,8 @@ function loadLocalState() {
37719
37815
  const backupPath = `${statePath}.corrupt.${Date.now()}`;
37720
37816
  fs4.copyFileSync(statePath, backupPath);
37721
37817
  warn(MODULE11, `Corrupt state backed up to ${backupPath}`);
37722
- } catch {
37818
+ } catch (backupErr) {
37819
+ warn(MODULE11, `Failed to back up corrupt state: ${errorMessage(backupErr)}`);
37723
37820
  }
37724
37821
  return ScoutStateSchema.parse({ version: 1 });
37725
37822
  }
@@ -37799,12 +37896,16 @@ var OssScout = class {
37799
37896
  // ── Search ──────────────────────────────────────────────────────────
37800
37897
  /**
37801
37898
  * Multi-strategy issue search. Returns scored, sorted candidates.
37899
+ * Automatically culls expired skip entries and filters skipped issues.
37802
37900
  */
37803
37901
  async search(options) {
37902
+ this.cullExpiredSkips();
37903
+ const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
37804
37904
  const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
37805
37905
  const { candidates, strategiesUsed } = await discovery.searchIssues({
37806
37906
  maxResults: options?.maxResults,
37807
- strategies: options?.strategies
37907
+ strategies: options?.strategies,
37908
+ skippedUrls
37808
37909
  });
37809
37910
  this.state.lastSearchAt = (/* @__PURE__ */ new Date()).toISOString();
37810
37911
  this.dirty = true;
@@ -37905,9 +38006,6 @@ var OssScout = class {
37905
38006
  getStarredRepos() {
37906
38007
  return this.state.starredRepos;
37907
38008
  }
37908
- getPreferredOrgs() {
37909
- return this.state.preferences.preferredOrgs;
37910
- }
37911
38009
  getProjectCategories() {
37912
38010
  return this.state.preferences.projectCategories;
37913
38011
  }
@@ -38038,6 +38136,69 @@ var OssScout = class {
38038
38136
  this.state.savedResults = [];
38039
38137
  this.dirty = true;
38040
38138
  }
38139
+ // ── Skip List ───────────────────────────────────────────────────────
38140
+ /**
38141
+ * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
38142
+ */
38143
+ skipIssue(url2, metadata) {
38144
+ const existing = this.state.skippedIssues ?? [];
38145
+ if (existing.some((s) => s.url === url2))
38146
+ return;
38147
+ this.state.skippedIssues = [
38148
+ ...existing,
38149
+ {
38150
+ url: url2,
38151
+ repo: metadata?.repo ?? "",
38152
+ number: metadata?.number ?? 0,
38153
+ title: metadata?.title ?? "",
38154
+ skippedAt: (/* @__PURE__ */ new Date()).toISOString()
38155
+ }
38156
+ ];
38157
+ if (this.state.savedResults) {
38158
+ this.state.savedResults = this.state.savedResults.filter((r) => r.issueUrl !== url2);
38159
+ }
38160
+ this.dirty = true;
38161
+ }
38162
+ /**
38163
+ * Get all skipped issues.
38164
+ */
38165
+ getSkippedIssues() {
38166
+ return this.state.skippedIssues ?? [];
38167
+ }
38168
+ /**
38169
+ * Remove a specific issue from the skip list.
38170
+ */
38171
+ unskipIssue(url2) {
38172
+ this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url2);
38173
+ this.dirty = true;
38174
+ }
38175
+ /**
38176
+ * Clear all skipped issues.
38177
+ */
38178
+ clearSkippedIssues() {
38179
+ this.state.skippedIssues = [];
38180
+ this.dirty = true;
38181
+ }
38182
+ /**
38183
+ * Remove skipped issues older than maxDays (default 90). Called automatically during search.
38184
+ * @returns The number of expired entries that were removed.
38185
+ */
38186
+ cullExpiredSkips(maxDays = 90) {
38187
+ const cutoff = /* @__PURE__ */ new Date();
38188
+ cutoff.setDate(cutoff.getDate() - maxDays);
38189
+ const before = (this.state.skippedIssues ?? []).length;
38190
+ this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => {
38191
+ const d = new Date(s.skippedAt);
38192
+ if (isNaN(d.getTime())) {
38193
+ return true;
38194
+ }
38195
+ return d >= cutoff;
38196
+ });
38197
+ const culled = before - this.state.skippedIssues.length;
38198
+ if (culled > 0)
38199
+ this.dirty = true;
38200
+ return culled;
38201
+ }
38041
38202
  // ── Persistence ─────────────────────────────────────────────────────
38042
38203
  /**
38043
38204
  * Check if state has uncommitted changes.
@@ -38097,6 +38258,17 @@ var OssScout = class {
38097
38258
  };
38098
38259
 
38099
38260
  // src/tools.ts
38261
+ var TOOL_TIMEOUT_MS = 6e4;
38262
+ function withTimeout(promise2, ms = TOOL_TIMEOUT_MS) {
38263
+ let timeoutId;
38264
+ const timer = new Promise((_, reject) => {
38265
+ timeoutId = setTimeout(
38266
+ () => reject(new Error(`Request timed out after ${ms / 1e3}s`)),
38267
+ ms
38268
+ );
38269
+ });
38270
+ return Promise.race([promise2, timer]).finally(() => clearTimeout(timeoutId));
38271
+ }
38100
38272
  function registerTools(server, scout) {
38101
38273
  server.tool(
38102
38274
  "search",
@@ -38104,16 +38276,18 @@ function registerTools(server, scout) {
38104
38276
  {
38105
38277
  maxResults: external_exports3.number().optional().describe("Maximum number of results to return (default 10)"),
38106
38278
  strategies: external_exports3.string().optional().describe(
38107
- "Comma-separated search strategies: merged, orgs, starred, broad, maintained, all"
38279
+ "Comma-separated search strategies: merged, starred, broad, maintained, all"
38108
38280
  )
38109
38281
  },
38110
38282
  async ({ maxResults, strategies }) => {
38111
38283
  try {
38112
38284
  const parsedStrategies = strategies ? strategies.split(",").map((s) => SearchStrategySchema.parse(s.trim())) : void 0;
38113
- const result = await scout.search({
38114
- maxResults: maxResults ?? 10,
38115
- strategies: parsedStrategies
38116
- });
38285
+ const result = await withTimeout(
38286
+ scout.search({
38287
+ maxResults: maxResults ?? 10,
38288
+ strategies: parsedStrategies
38289
+ })
38290
+ );
38117
38291
  scout.saveResults(result.candidates);
38118
38292
  await scout.checkpoint();
38119
38293
  return {
@@ -38138,7 +38312,7 @@ function registerTools(server, scout) {
38138
38312
  },
38139
38313
  async ({ issueUrl }) => {
38140
38314
  try {
38141
- const candidate = await scout.vetIssue(issueUrl);
38315
+ const candidate = await withTimeout(scout.vetIssue(issueUrl));
38142
38316
  return {
38143
38317
  content: [{ type: "text", text: JSON.stringify(candidate, null, 2) }]
38144
38318
  };
@@ -38161,11 +38335,93 @@ function registerTools(server, scout) {
38161
38335
  };
38162
38336
  }
38163
38337
  );
38338
+ server.tool(
38339
+ "skip",
38340
+ "Skip, unskip, list, or clear skipped issues. Skipped issues are excluded from future searches and auto-expire after 90 days.",
38341
+ {
38342
+ action: external_exports3.enum(["add", "remove", "list", "clear"]).default("add").describe("Action to perform (default: add)"),
38343
+ issueUrl: external_exports3.string().optional().describe("GitHub issue URL (required for add/remove actions)")
38344
+ },
38345
+ async ({ action, issueUrl }) => {
38346
+ try {
38347
+ if ((action === "add" || action === "remove") && !issueUrl) {
38348
+ return {
38349
+ content: [
38350
+ {
38351
+ type: "text",
38352
+ text: `Error: issueUrl is required for the "${action}" action.`
38353
+ }
38354
+ ],
38355
+ isError: true
38356
+ };
38357
+ }
38358
+ if (action === "list") {
38359
+ const skipped = scout.getSkippedIssues();
38360
+ return {
38361
+ content: [{ type: "text", text: JSON.stringify(skipped, null, 2) }]
38362
+ };
38363
+ }
38364
+ if (action === "clear") {
38365
+ const count = scout.getSkippedIssues().length;
38366
+ scout.clearSkippedIssues();
38367
+ const synced2 = await scout.checkpoint();
38368
+ const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
38369
+ return {
38370
+ content: [
38371
+ {
38372
+ type: "text",
38373
+ text: count > 0 ? `Skip list cleared (${count} entries removed)${syncNote2}` : `Skip list already empty${syncNote2}`
38374
+ }
38375
+ ]
38376
+ };
38377
+ }
38378
+ if (action === "remove") {
38379
+ const wasPresent = scout.getSkippedIssues().some((s) => s.url === issueUrl);
38380
+ scout.unskipIssue(issueUrl);
38381
+ const synced2 = await scout.checkpoint();
38382
+ const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
38383
+ return {
38384
+ content: [
38385
+ {
38386
+ type: "text",
38387
+ text: wasPresent ? `Removed from skip list: ${issueUrl}${syncNote2}` : `Not in skip list: ${issueUrl}`
38388
+ }
38389
+ ]
38390
+ };
38391
+ }
38392
+ const alreadySkipped = scout.getSkippedIssues().some((s) => s.url === issueUrl);
38393
+ if (alreadySkipped) {
38394
+ return {
38395
+ content: [
38396
+ { type: "text", text: `Already in skip list: ${issueUrl}` }
38397
+ ]
38398
+ };
38399
+ }
38400
+ const saved = scout.getSavedResults().find((r) => r.issueUrl === issueUrl);
38401
+ const metadata = saved ? {
38402
+ repo: saved.repo,
38403
+ number: saved.number,
38404
+ title: saved.title
38405
+ } : void 0;
38406
+ scout.skipIssue(issueUrl, metadata);
38407
+ const synced = await scout.checkpoint();
38408
+ const syncNote = synced ? "" : " (saved locally, gist sync failed)";
38409
+ return {
38410
+ content: [{ type: "text", text: `Skipped: ${issueUrl}${syncNote}` }]
38411
+ };
38412
+ } catch (err) {
38413
+ const msg = err instanceof Error ? err.message : String(err);
38414
+ return {
38415
+ content: [{ type: "text", text: `Error: ${msg}` }],
38416
+ isError: true
38417
+ };
38418
+ }
38419
+ }
38420
+ );
38164
38421
  const VALID_KEYS = Object.keys(ScoutPreferencesSchema.shape);
38165
38422
  const ARRAY_KEYS = /* @__PURE__ */ new Set([
38166
38423
  "languages",
38167
38424
  "labels",
38168
- "preferredOrgs",
38169
38425
  "projectCategories",
38170
38426
  "excludeRepos",
38171
38427
  "excludeOrgs",
@@ -38176,7 +38432,10 @@ function registerTools(server, scout) {
38176
38432
  const NUMBER_KEYS = /* @__PURE__ */ new Set([
38177
38433
  "minStars",
38178
38434
  "maxIssueAgeDays",
38179
- "minRepoScoreThreshold"
38435
+ "minRepoScoreThreshold",
38436
+ "interPhaseDelayMs",
38437
+ "broadPhaseDelayMs",
38438
+ "skipBroadWhenSufficientResults"
38180
38439
  ]);
38181
38440
  const BOOLEAN_KEYS = /* @__PURE__ */ new Set(["includeDocIssues"]);
38182
38441
  server.tool(
@@ -38321,7 +38580,7 @@ async function main() {
38321
38580
  }
38322
38581
  const scout = await createScout({ githubToken: token });
38323
38582
  const server = new McpServer(
38324
- { name: "@oss-scout/core", version: "0.1.0" },
38583
+ { name: "oss-scout-mcp", version: "0.2.1" },
38325
38584
  { capabilities: { resources: {}, tools: {} } }
38326
38585
  );
38327
38586
  registerTools(server, scout);
package/dist/tools.js CHANGED
@@ -1,5 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import { SearchStrategySchema, ScoutPreferencesSchema } from "@oss-scout/core";
3
+ const TOOL_TIMEOUT_MS = 60000;
4
+ function withTimeout(promise, ms = TOOL_TIMEOUT_MS) {
5
+ let timeoutId;
6
+ const timer = new Promise((_, reject) => {
7
+ timeoutId = setTimeout(() => reject(new Error(`Request timed out after ${ms / 1000}s`)), ms);
8
+ });
9
+ return Promise.race([promise, timer]).finally(() => clearTimeout(timeoutId));
10
+ }
3
11
  export function registerTools(server, scout) {
4
12
  server.tool("search", "Search for open source issues matching your preferences", {
5
13
  maxResults: z
@@ -9,7 +17,7 @@ export function registerTools(server, scout) {
9
17
  strategies: z
10
18
  .string()
11
19
  .optional()
12
- .describe("Comma-separated search strategies: merged, orgs, starred, broad, maintained, all"),
20
+ .describe("Comma-separated search strategies: merged, starred, broad, maintained, all"),
13
21
  }, async ({ maxResults, strategies }) => {
14
22
  try {
15
23
  const parsedStrategies = strategies
@@ -17,10 +25,10 @@ export function registerTools(server, scout) {
17
25
  .split(",")
18
26
  .map((s) => SearchStrategySchema.parse(s.trim()))
19
27
  : undefined;
20
- const result = await scout.search({
28
+ const result = await withTimeout(scout.search({
21
29
  maxResults: maxResults ?? 10,
22
30
  strategies: parsedStrategies,
23
- });
31
+ }));
24
32
  scout.saveResults(result.candidates);
25
33
  await scout.checkpoint();
26
34
  return {
@@ -41,7 +49,7 @@ export function registerTools(server, scout) {
41
49
  .describe("Full GitHub issue URL (e.g. https://github.com/owner/repo/issues/123)"),
42
50
  }, async ({ issueUrl }) => {
43
51
  try {
44
- const candidate = await scout.vetIssue(issueUrl);
52
+ const candidate = await withTimeout(scout.vetIssue(issueUrl));
45
53
  return {
46
54
  content: [{ type: "text", text: JSON.stringify(candidate, null, 2) }],
47
55
  };
@@ -60,11 +68,108 @@ export function registerTools(server, scout) {
60
68
  content: [{ type: "text", text: JSON.stringify(preferences, null, 2) }],
61
69
  };
62
70
  });
71
+ server.tool("skip", "Skip, unskip, list, or clear skipped issues. Skipped issues are excluded from future searches and auto-expire after 90 days.", {
72
+ action: z
73
+ .enum(["add", "remove", "list", "clear"])
74
+ .default("add")
75
+ .describe("Action to perform (default: add)"),
76
+ issueUrl: z
77
+ .string()
78
+ .optional()
79
+ .describe("GitHub issue URL (required for add/remove actions)"),
80
+ }, async ({ action, issueUrl }) => {
81
+ try {
82
+ if ((action === "add" || action === "remove") && !issueUrl) {
83
+ return {
84
+ content: [
85
+ {
86
+ type: "text",
87
+ text: `Error: issueUrl is required for the "${action}" action.`,
88
+ },
89
+ ],
90
+ isError: true,
91
+ };
92
+ }
93
+ if (action === "list") {
94
+ const skipped = scout.getSkippedIssues();
95
+ return {
96
+ content: [{ type: "text", text: JSON.stringify(skipped, null, 2) }],
97
+ };
98
+ }
99
+ if (action === "clear") {
100
+ const count = scout.getSkippedIssues().length;
101
+ scout.clearSkippedIssues();
102
+ const synced = await scout.checkpoint();
103
+ const syncNote = synced ? "" : " (saved locally, gist sync failed)";
104
+ return {
105
+ content: [
106
+ {
107
+ type: "text",
108
+ text: count > 0
109
+ ? `Skip list cleared (${count} entries removed)${syncNote}`
110
+ : `Skip list already empty${syncNote}`,
111
+ },
112
+ ],
113
+ };
114
+ }
115
+ if (action === "remove") {
116
+ const wasPresent = scout
117
+ .getSkippedIssues()
118
+ .some((s) => s.url === issueUrl);
119
+ scout.unskipIssue(issueUrl);
120
+ const synced = await scout.checkpoint();
121
+ const syncNote = synced ? "" : " (saved locally, gist sync failed)";
122
+ return {
123
+ content: [
124
+ {
125
+ type: "text",
126
+ text: wasPresent
127
+ ? `Removed from skip list: ${issueUrl}${syncNote}`
128
+ : `Not in skip list: ${issueUrl}`,
129
+ },
130
+ ],
131
+ };
132
+ }
133
+ // action === "add"
134
+ const alreadySkipped = scout
135
+ .getSkippedIssues()
136
+ .some((s) => s.url === issueUrl);
137
+ if (alreadySkipped) {
138
+ return {
139
+ content: [
140
+ { type: "text", text: `Already in skip list: ${issueUrl}` },
141
+ ],
142
+ };
143
+ }
144
+ const saved = scout
145
+ .getSavedResults()
146
+ .find((r) => r.issueUrl === issueUrl);
147
+ const metadata = saved
148
+ ? {
149
+ repo: saved.repo,
150
+ number: saved.number,
151
+ title: saved.title,
152
+ }
153
+ : undefined;
154
+ scout.skipIssue(issueUrl, metadata);
155
+ const synced = await scout.checkpoint();
156
+ const syncNote = synced ? "" : " (saved locally, gist sync failed)";
157
+ return {
158
+ content: [{ type: "text", text: `Skipped: ${issueUrl}${syncNote}` }],
159
+ };
160
+ }
161
+ catch (err) {
162
+ const msg = err instanceof Error ? err.message : String(err);
163
+ return {
164
+ content: [{ type: "text", text: `Error: ${msg}` }],
165
+ isError: true,
166
+ };
167
+ }
168
+ });
63
169
  const VALID_KEYS = Object.keys(ScoutPreferencesSchema.shape);
64
170
  const ARRAY_KEYS = new Set([
65
171
  "languages",
66
172
  "labels",
67
- "preferredOrgs",
68
173
  "projectCategories",
69
174
  "excludeRepos",
70
175
  "excludeOrgs",
@@ -76,6 +181,9 @@ export function registerTools(server, scout) {
76
181
  "minStars",
77
182
  "maxIssueAgeDays",
78
183
  "minRepoScoreThreshold",
184
+ "interPhaseDelayMs",
185
+ "broadPhaseDelayMs",
186
+ "skipBroadWhenSufficientResults",
79
187
  ]);
80
188
  const BOOLEAN_KEYS = new Set(["includeDocIssues"]);
81
189
  server.tool("config-set", "Update an oss-scout configuration preference", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/mcp",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for oss-scout — exposes issue discovery and vetting as MCP tools",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,19 +15,10 @@
15
15
  "files": [
16
16
  "dist/"
17
17
  ],
18
- "scripts": {
19
- "build": "tsc",
20
- "bundle": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/mcp-server.bundle.cjs",
21
- "test": "vitest run",
22
- "test:watch": "vitest",
23
- "test:coverage": "vitest run --coverage",
24
- "typecheck": "tsc --noEmit",
25
- "prepublishOnly": "pnpm run build && pnpm run bundle"
26
- },
27
18
  "dependencies": {
28
19
  "@modelcontextprotocol/sdk": "^1.27.1",
29
- "@oss-scout/core": "workspace:^",
30
- "zod": "^4.3.6"
20
+ "zod": "^4.3.6",
21
+ "@oss-scout/core": "^0.4.0"
31
22
  },
32
23
  "devDependencies": {
33
24
  "@types/node": "^25.5.0",
@@ -37,6 +28,12 @@
37
28
  "@vitest/coverage-v8": "^4.1.0",
38
29
  "vitest": "^4.1.0"
39
30
  },
31
+ "keywords": [
32
+ "mcp",
33
+ "mcp-server",
34
+ "claude",
35
+ "issue-discovery"
36
+ ],
40
37
  "engines": {
41
38
  "node": ">=20"
42
39
  },
@@ -45,5 +42,13 @@
45
42
  "type": "git",
46
43
  "url": "git+https://github.com/costajohnt/oss-scout.git",
47
44
  "directory": "packages/mcp-server"
45
+ },
46
+ "scripts": {
47
+ "build": "tsc",
48
+ "bundle": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/mcp-server.bundle.cjs",
49
+ "test": "vitest run",
50
+ "test:watch": "vitest",
51
+ "test:coverage": "vitest run --coverage",
52
+ "typecheck": "tsc --noEmit"
48
53
  }
49
- }
54
+ }