@oss-scout/mcp 0.2.1 → 0.3.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,13 +35806,12 @@ 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),
@@ -35825,6 +35829,7 @@ var ScoutStateSchema = external_exports3.object({
35825
35829
  mergedPRs: external_exports3.array(StoredMergedPRSchema).default([]),
35826
35830
  closedPRs: external_exports3.array(StoredClosedPRSchema).default([]),
35827
35831
  savedResults: external_exports3.array(SavedCandidateSchema).default([]),
35832
+ skippedIssues: external_exports3.array(SkippedIssueSchema).default([]),
35828
35833
  lastSearchAt: external_exports3.string().optional(),
35829
35834
  lastRunAt: external_exports3.string().default(() => (/* @__PURE__ */ new Date()).toISOString()),
35830
35835
  gistId: external_exports3.string().optional()
@@ -36760,12 +36765,9 @@ var IssueVetter = class {
36760
36765
  matchesPreferredCategory: matchesCategory
36761
36766
  });
36762
36767
  const starredRepos = this.stateReader.getStarredRepos();
36763
- const preferredOrgs = this.stateReader.getPreferredOrgs();
36764
36768
  let searchPriority = "normal";
36765
36769
  if (effectiveMergedCount > 0) {
36766
36770
  searchPriority = "merged_pr";
36767
- } else if (preferredOrgs.some((o) => o.toLowerCase() === orgName?.toLowerCase())) {
36768
- searchPriority = "preferred_org";
36769
36771
  } else if (starredRepos.includes(repoFullName)) {
36770
36772
  searchPriority = "starred";
36771
36773
  }
@@ -37015,6 +37017,8 @@ function buildIssueFilter(config2) {
37015
37017
  return false;
37016
37018
  if (config2.lowScoringRepos.has(repoFullName))
37017
37019
  return false;
37020
+ if (config2.skippedUrls.has(item.html_url))
37021
+ return false;
37018
37022
  const updatedAt = new Date(item.updated_at);
37019
37023
  const ageDays = daysBetween(updatedAt, config2.now);
37020
37024
  if (ageDays > config2.maxAgeDays)
@@ -37035,38 +37039,6 @@ async function runPhase0(octokit, vetter, repos, baseQualifiers, maxResults, fil
37035
37039
  rateLimitHit
37036
37040
  };
37037
37041
  }
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
37042
  async function runPhase1(octokit, vetter, repos, baseQualifiers, labels, maxResults, filterIssues) {
37071
37043
  info(MODULE9, `Phase 1: Searching issues in ${repos.length} starred repos...`);
37072
37044
  const phase1Labels = labels.slice(0, 3);
@@ -37203,8 +37175,8 @@ var IssueDiscovery = class {
37203
37175
  }
37204
37176
  /**
37205
37177
  * 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.
37178
+ * Searches in priority order: merged-PR repos first (no label filter), then starred
37179
+ * repos, then general search, then actively maintained repos.
37208
37180
  * Filters out issues from low-scoring and excluded repos.
37209
37181
  *
37210
37182
  * @param options - Search configuration
@@ -37262,6 +37234,10 @@ var IssueDiscovery = class {
37262
37234
  tracker.init(CRITICAL_BUDGET_THRESHOLD, new Date(Date.now() + 6e4).toISOString());
37263
37235
  warn(MODULE9, "Could not check rate limit \u2014 using conservative budget, skipping heavy phases:", errorMessage(error48));
37264
37236
  }
37237
+ if (searchBudget <= 0) {
37238
+ this.rateLimitWarning = "GitHub search API quota exhausted. Try again after the rate limit resets.";
37239
+ return { candidates: [], strategiesUsed: [] };
37240
+ }
37265
37241
  const mergedPRRepos = this.stateReader.getReposWithMergedPRs();
37266
37242
  const starredRepos = this.getStarredRepos();
37267
37243
  const starredRepoSet = new Set(starredRepos);
@@ -37278,6 +37254,7 @@ var IssueDiscovery = class {
37278
37254
  excludeOrgs: new Set((config2.excludeOrgs ?? []).map((o) => o.toLowerCase())),
37279
37255
  aiBlocklisted,
37280
37256
  lowScoringRepos,
37257
+ skippedUrls: options.skippedUrls ?? /* @__PURE__ */ new Set(),
37281
37258
  maxAgeDays: config2.maxIssueAgeDays || 90,
37282
37259
  now: /* @__PURE__ */ new Date(),
37283
37260
  includeDocIssues: config2.includeDocIssues ?? true
@@ -37295,22 +37272,6 @@ var IssueDiscovery = class {
37295
37272
  }
37296
37273
  strategiesUsed.push("merged");
37297
37274
  }
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
37275
  if (allCandidates.length < maxResults && starredRepos.length > 0 && searchBudget >= CRITICAL_BUDGET_THRESHOLD && enabledStrategies.has("starred")) {
37315
37276
  await sleep(INTER_PHASE_DELAY_MS);
37316
37277
  const reposToSearch = starredRepos.filter((r) => !phase0RepoSet.has(r));
@@ -37356,7 +37317,6 @@ var IssueDiscovery = class {
37356
37317
  if (allCandidates.length === 0) {
37357
37318
  const errorDetails = [
37358
37319
  phaseErrors["0"] ? `Phase 0 (merged-PR repos): ${phaseErrors["0"]}` : null,
37359
- phaseErrors["0.5"] ? `Phase 0.5 (preferred orgs): ${phaseErrors["0.5"]}` : null,
37360
37320
  phaseErrors["1"] ? `Phase 1 (starred repos): ${phaseErrors["1"]}` : null,
37361
37321
  phaseErrors["2"] ? `Phase 2 (general): ${phaseErrors["2"]}` : null,
37362
37322
  phaseErrors["3"] ? `Phase 3 (maintained repos): ${phaseErrors["3"]}` : null
@@ -37374,9 +37334,8 @@ var IssueDiscovery = class {
37374
37334
  allCandidates.sort((a, b) => {
37375
37335
  const priorityOrder = {
37376
37336
  merged_pr: 0,
37377
- preferred_org: 1,
37378
- starred: 2,
37379
- normal: 3
37337
+ starred: 1,
37338
+ normal: 2
37380
37339
  };
37381
37340
  const priorityDiff = priorityOrder[a.searchPriority] - priorityOrder[b.searchPriority];
37382
37341
  if (priorityDiff !== 0)
@@ -37462,11 +37421,16 @@ var GistStateStore = class {
37462
37421
  warn(MODULE10, "No gist ID \u2014 cannot push");
37463
37422
  return false;
37464
37423
  }
37424
+ const json2 = JSON.stringify(state, null, 2);
37425
+ if (json2.length > 9e5) {
37426
+ warn(MODULE10, `State too large for gist (${Math.round(json2.length / 1024)}KB). Consider clearing old results with 'oss-scout results clear'.`);
37427
+ return false;
37428
+ }
37465
37429
  try {
37466
37430
  await this.octokit.gists.update({
37467
37431
  gist_id: this.gistId,
37468
37432
  files: {
37469
- [GIST_FILENAME]: { content: JSON.stringify(state, null, 2) }
37433
+ [GIST_FILENAME]: { content: json2 }
37470
37434
  }
37471
37435
  });
37472
37436
  debug(MODULE10, "State pushed to gist");
@@ -37639,6 +37603,7 @@ function mergeStates(local, remote) {
37639
37603
  mergedPRs: unionByUrl(local.mergedPRs, remote.mergedPRs),
37640
37604
  closedPRs: unionByUrl(local.closedPRs, remote.closedPRs),
37641
37605
  savedResults: mergeSavedResults(local.savedResults ?? [], remote.savedResults ?? []),
37606
+ skippedIssues: mergeSkippedIssues(local.skippedIssues ?? [], remote.skippedIssues ?? []),
37642
37607
  lastSearchAt: pickFresherTimestamp(local.lastSearchAt, remote.lastSearchAt),
37643
37608
  lastRunAt: pickFresherTimestamp(local.lastRunAt, remote.lastRunAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
37644
37609
  gistId: remote.gistId ?? local.gistId
@@ -37689,6 +37654,18 @@ function mergeSavedResults(local, remote) {
37689
37654
  }
37690
37655
  return [...merged.values()];
37691
37656
  }
37657
+ function mergeSkippedIssues(local, remote) {
37658
+ const merged = /* @__PURE__ */ new Map();
37659
+ for (const item of local)
37660
+ merged.set(item.url, item);
37661
+ for (const item of remote) {
37662
+ const existing = merged.get(item.url);
37663
+ if (!existing || item.skippedAt > existing.skippedAt) {
37664
+ merged.set(item.url, item);
37665
+ }
37666
+ }
37667
+ return [...merged.values()];
37668
+ }
37692
37669
  function pickFresherTimestamp(a, b) {
37693
37670
  if (!a)
37694
37671
  return b;
@@ -37719,7 +37696,8 @@ function loadLocalState() {
37719
37696
  const backupPath = `${statePath}.corrupt.${Date.now()}`;
37720
37697
  fs4.copyFileSync(statePath, backupPath);
37721
37698
  warn(MODULE11, `Corrupt state backed up to ${backupPath}`);
37722
- } catch {
37699
+ } catch (backupErr) {
37700
+ warn(MODULE11, `Failed to back up corrupt state: ${errorMessage(backupErr)}`);
37723
37701
  }
37724
37702
  return ScoutStateSchema.parse({ version: 1 });
37725
37703
  }
@@ -37799,12 +37777,16 @@ var OssScout = class {
37799
37777
  // ── Search ──────────────────────────────────────────────────────────
37800
37778
  /**
37801
37779
  * Multi-strategy issue search. Returns scored, sorted candidates.
37780
+ * Automatically culls expired skip entries and filters skipped issues.
37802
37781
  */
37803
37782
  async search(options) {
37783
+ this.cullExpiredSkips();
37784
+ const skippedUrls = new Set((this.state.skippedIssues ?? []).map((s) => s.url));
37804
37785
  const discovery = new IssueDiscovery(this.githubToken, this.state.preferences, this);
37805
37786
  const { candidates, strategiesUsed } = await discovery.searchIssues({
37806
37787
  maxResults: options?.maxResults,
37807
- strategies: options?.strategies
37788
+ strategies: options?.strategies,
37789
+ skippedUrls
37808
37790
  });
37809
37791
  this.state.lastSearchAt = (/* @__PURE__ */ new Date()).toISOString();
37810
37792
  this.dirty = true;
@@ -37905,9 +37887,6 @@ var OssScout = class {
37905
37887
  getStarredRepos() {
37906
37888
  return this.state.starredRepos;
37907
37889
  }
37908
- getPreferredOrgs() {
37909
- return this.state.preferences.preferredOrgs;
37910
- }
37911
37890
  getProjectCategories() {
37912
37891
  return this.state.preferences.projectCategories;
37913
37892
  }
@@ -38038,6 +38017,69 @@ var OssScout = class {
38038
38017
  this.state.savedResults = [];
38039
38018
  this.dirty = true;
38040
38019
  }
38020
+ // ── Skip List ───────────────────────────────────────────────────────
38021
+ /**
38022
+ * Skip an issue — excludes it from future searches. Auto-culled after 90 days.
38023
+ */
38024
+ skipIssue(url2, metadata) {
38025
+ const existing = this.state.skippedIssues ?? [];
38026
+ if (existing.some((s) => s.url === url2))
38027
+ return;
38028
+ this.state.skippedIssues = [
38029
+ ...existing,
38030
+ {
38031
+ url: url2,
38032
+ repo: metadata?.repo ?? "",
38033
+ number: metadata?.number ?? 0,
38034
+ title: metadata?.title ?? "",
38035
+ skippedAt: (/* @__PURE__ */ new Date()).toISOString()
38036
+ }
38037
+ ];
38038
+ if (this.state.savedResults) {
38039
+ this.state.savedResults = this.state.savedResults.filter((r) => r.issueUrl !== url2);
38040
+ }
38041
+ this.dirty = true;
38042
+ }
38043
+ /**
38044
+ * Get all skipped issues.
38045
+ */
38046
+ getSkippedIssues() {
38047
+ return this.state.skippedIssues ?? [];
38048
+ }
38049
+ /**
38050
+ * Remove a specific issue from the skip list.
38051
+ */
38052
+ unskipIssue(url2) {
38053
+ this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => s.url !== url2);
38054
+ this.dirty = true;
38055
+ }
38056
+ /**
38057
+ * Clear all skipped issues.
38058
+ */
38059
+ clearSkippedIssues() {
38060
+ this.state.skippedIssues = [];
38061
+ this.dirty = true;
38062
+ }
38063
+ /**
38064
+ * Remove skipped issues older than maxDays (default 90). Called automatically during search.
38065
+ * @returns The number of expired entries that were removed.
38066
+ */
38067
+ cullExpiredSkips(maxDays = 90) {
38068
+ const cutoff = /* @__PURE__ */ new Date();
38069
+ cutoff.setDate(cutoff.getDate() - maxDays);
38070
+ const before = (this.state.skippedIssues ?? []).length;
38071
+ this.state.skippedIssues = (this.state.skippedIssues ?? []).filter((s) => {
38072
+ const d = new Date(s.skippedAt);
38073
+ if (isNaN(d.getTime())) {
38074
+ return true;
38075
+ }
38076
+ return d >= cutoff;
38077
+ });
38078
+ const culled = before - this.state.skippedIssues.length;
38079
+ if (culled > 0)
38080
+ this.dirty = true;
38081
+ return culled;
38082
+ }
38041
38083
  // ── Persistence ─────────────────────────────────────────────────────
38042
38084
  /**
38043
38085
  * Check if state has uncommitted changes.
@@ -38097,6 +38139,17 @@ var OssScout = class {
38097
38139
  };
38098
38140
 
38099
38141
  // src/tools.ts
38142
+ var TOOL_TIMEOUT_MS = 6e4;
38143
+ function withTimeout(promise2, ms = TOOL_TIMEOUT_MS) {
38144
+ let timeoutId;
38145
+ const timer = new Promise((_, reject) => {
38146
+ timeoutId = setTimeout(
38147
+ () => reject(new Error(`Request timed out after ${ms / 1e3}s`)),
38148
+ ms
38149
+ );
38150
+ });
38151
+ return Promise.race([promise2, timer]).finally(() => clearTimeout(timeoutId));
38152
+ }
38100
38153
  function registerTools(server, scout) {
38101
38154
  server.tool(
38102
38155
  "search",
@@ -38104,16 +38157,18 @@ function registerTools(server, scout) {
38104
38157
  {
38105
38158
  maxResults: external_exports3.number().optional().describe("Maximum number of results to return (default 10)"),
38106
38159
  strategies: external_exports3.string().optional().describe(
38107
- "Comma-separated search strategies: merged, orgs, starred, broad, maintained, all"
38160
+ "Comma-separated search strategies: merged, starred, broad, maintained, all"
38108
38161
  )
38109
38162
  },
38110
38163
  async ({ maxResults, strategies }) => {
38111
38164
  try {
38112
38165
  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
- });
38166
+ const result = await withTimeout(
38167
+ scout.search({
38168
+ maxResults: maxResults ?? 10,
38169
+ strategies: parsedStrategies
38170
+ })
38171
+ );
38117
38172
  scout.saveResults(result.candidates);
38118
38173
  await scout.checkpoint();
38119
38174
  return {
@@ -38138,7 +38193,7 @@ function registerTools(server, scout) {
38138
38193
  },
38139
38194
  async ({ issueUrl }) => {
38140
38195
  try {
38141
- const candidate = await scout.vetIssue(issueUrl);
38196
+ const candidate = await withTimeout(scout.vetIssue(issueUrl));
38142
38197
  return {
38143
38198
  content: [{ type: "text", text: JSON.stringify(candidate, null, 2) }]
38144
38199
  };
@@ -38161,11 +38216,93 @@ function registerTools(server, scout) {
38161
38216
  };
38162
38217
  }
38163
38218
  );
38219
+ server.tool(
38220
+ "skip",
38221
+ "Skip, unskip, list, or clear skipped issues. Skipped issues are excluded from future searches and auto-expire after 90 days.",
38222
+ {
38223
+ action: external_exports3.enum(["add", "remove", "list", "clear"]).default("add").describe("Action to perform (default: add)"),
38224
+ issueUrl: external_exports3.string().optional().describe("GitHub issue URL (required for add/remove actions)")
38225
+ },
38226
+ async ({ action, issueUrl }) => {
38227
+ try {
38228
+ if ((action === "add" || action === "remove") && !issueUrl) {
38229
+ return {
38230
+ content: [
38231
+ {
38232
+ type: "text",
38233
+ text: `Error: issueUrl is required for the "${action}" action.`
38234
+ }
38235
+ ],
38236
+ isError: true
38237
+ };
38238
+ }
38239
+ if (action === "list") {
38240
+ const skipped = scout.getSkippedIssues();
38241
+ return {
38242
+ content: [{ type: "text", text: JSON.stringify(skipped, null, 2) }]
38243
+ };
38244
+ }
38245
+ if (action === "clear") {
38246
+ const count = scout.getSkippedIssues().length;
38247
+ scout.clearSkippedIssues();
38248
+ const synced2 = await scout.checkpoint();
38249
+ const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
38250
+ return {
38251
+ content: [
38252
+ {
38253
+ type: "text",
38254
+ text: count > 0 ? `Skip list cleared (${count} entries removed)${syncNote2}` : `Skip list already empty${syncNote2}`
38255
+ }
38256
+ ]
38257
+ };
38258
+ }
38259
+ if (action === "remove") {
38260
+ const wasPresent = scout.getSkippedIssues().some((s) => s.url === issueUrl);
38261
+ scout.unskipIssue(issueUrl);
38262
+ const synced2 = await scout.checkpoint();
38263
+ const syncNote2 = synced2 ? "" : " (saved locally, gist sync failed)";
38264
+ return {
38265
+ content: [
38266
+ {
38267
+ type: "text",
38268
+ text: wasPresent ? `Removed from skip list: ${issueUrl}${syncNote2}` : `Not in skip list: ${issueUrl}`
38269
+ }
38270
+ ]
38271
+ };
38272
+ }
38273
+ const alreadySkipped = scout.getSkippedIssues().some((s) => s.url === issueUrl);
38274
+ if (alreadySkipped) {
38275
+ return {
38276
+ content: [
38277
+ { type: "text", text: `Already in skip list: ${issueUrl}` }
38278
+ ]
38279
+ };
38280
+ }
38281
+ const saved = scout.getSavedResults().find((r) => r.issueUrl === issueUrl);
38282
+ const metadata = saved ? {
38283
+ repo: saved.repo,
38284
+ number: saved.number,
38285
+ title: saved.title
38286
+ } : void 0;
38287
+ scout.skipIssue(issueUrl, metadata);
38288
+ const synced = await scout.checkpoint();
38289
+ const syncNote = synced ? "" : " (saved locally, gist sync failed)";
38290
+ return {
38291
+ content: [{ type: "text", text: `Skipped: ${issueUrl}${syncNote}` }]
38292
+ };
38293
+ } catch (err) {
38294
+ const msg = err instanceof Error ? err.message : String(err);
38295
+ return {
38296
+ content: [{ type: "text", text: `Error: ${msg}` }],
38297
+ isError: true
38298
+ };
38299
+ }
38300
+ }
38301
+ );
38164
38302
  const VALID_KEYS = Object.keys(ScoutPreferencesSchema.shape);
38165
38303
  const ARRAY_KEYS = /* @__PURE__ */ new Set([
38166
38304
  "languages",
38167
38305
  "labels",
38168
- "preferredOrgs",
38169
38306
  "projectCategories",
38170
38307
  "excludeRepos",
38171
38308
  "excludeOrgs",
@@ -38321,7 +38458,7 @@ async function main() {
38321
38458
  }
38322
38459
  const scout = await createScout({ githubToken: token });
38323
38460
  const server = new McpServer(
38324
- { name: "@oss-scout/core", version: "0.1.0" },
38461
+ { name: "oss-scout-mcp", version: "0.2.1" },
38325
38462
  { capabilities: { resources: {}, tools: {} } }
38326
38463
  );
38327
38464
  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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/mcp",
3
- "version": "0.2.1",
3
+ "version": "0.3.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.3.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
+ }