@oss-scout/mcp 0.8.0 → 0.10.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/dist/tools.js CHANGED
@@ -1,6 +1,21 @@
1
1
  import { z } from "zod";
2
- import { SearchStrategySchema, ScoutPreferencesSchema } from "@oss-scout/core";
3
- const TOOL_TIMEOUT_MS = 60000;
2
+ import { SearchStrategySchema, ISSUE_URL_PATTERN, validateGitHubUrl, validateUrl, applyPreferenceField, SORTED_PREFERENCE_KEYS, } from "@oss-scout/core";
3
+ // Generous ceiling: even with inter-phase delays zeroed, vetting many issues
4
+ // under the budget tracker's sliding-window pacing can take minutes (#143).
5
+ const TOOL_TIMEOUT_MS = 300000;
6
+ // Surfaced on the JSON envelope when scout.checkpoint() returns false (#212).
7
+ // The skip tool reports this inline because it returns a short status line;
8
+ // the JSON tools (search, scout-features, config-set) carry it as a top-level
9
+ // field so a persist failure isn't silently swallowed.
10
+ const PERSIST_WARNING = "Checkpoint failed: changes were applied in memory but could not be persisted. They may be lost when the server restarts.";
11
+ /**
12
+ * Serialize a tool result, attaching a top-level `persistWarning` field when
13
+ * the preceding checkpoint failed (#212). Spreads onto the existing object so
14
+ * the happy-path payload is byte-for-byte unchanged.
15
+ */
16
+ function withPersistWarning(payload, persisted) {
17
+ return persisted ? payload : { ...payload, persistWarning: PERSIST_WARNING };
18
+ }
4
19
  function withTimeout(promise, ms = TOOL_TIMEOUT_MS) {
5
20
  let timeoutId;
6
21
  const timer = new Promise((_, reject) => {
@@ -26,13 +41,21 @@ export function registerTools(server, scout) {
26
41
  .array(z.string())
27
42
  .optional()
28
43
  .describe("Soft-boost ranking for candidates in one of these `owner/repo` slugs. Stronger weight than language match (#1244). Not a filter; only reorders."),
44
+ avoidRepos: z
45
+ .array(z.string())
46
+ .optional()
47
+ .describe("Soft PENALTY (milder than excludeRepos) for candidates in one of these `owner/repo` slugs (#168). Pushes them down the ranking but does not remove them; a strong boost can still outweigh it."),
48
+ boostIssueTypes: z
49
+ .array(z.string())
50
+ .optional()
51
+ .describe('Soft-boost ranking for candidates whose issue labels match one of these types, case-insensitive (e.g. "bug", "good first issue") (#168). Not a filter; only reorders.'),
29
52
  diversityRatio: z
30
53
  .number()
31
54
  .min(0)
32
55
  .max(1)
33
56
  .optional()
34
57
  .describe("Diversity counterweight (#1244): fraction of result slots reserved for candidates that matched NEITHER preference list. Counters echo-chamber bias when boosts accumulate over time. 0 disables, 1 reserves every slot for diversity. Default 0."),
35
- }, async ({ maxResults, strategies, preferLanguages, preferRepos, diversityRatio, }) => {
58
+ }, async ({ maxResults, strategies, preferLanguages, preferRepos, avoidRepos, boostIssueTypes, diversityRatio, }) => {
36
59
  try {
37
60
  const parsedStrategies = strategies
38
61
  ? strategies
@@ -44,12 +67,23 @@ export function registerTools(server, scout) {
44
67
  strategies: parsedStrategies,
45
68
  preferLanguages,
46
69
  preferRepos,
70
+ avoidRepos,
71
+ boostIssueTypes,
47
72
  diversityRatio,
73
+ // No fixed inter-phase sleeps in the request/response MCP context;
74
+ // the budget tracker still paces the GitHub calls (#143)
75
+ interPhaseDelayMs: 0,
76
+ broadPhaseDelayMs: 0,
48
77
  }));
49
78
  scout.saveResults(result.candidates);
50
- await scout.checkpoint();
79
+ const persisted = await scout.checkpoint();
51
80
  return {
52
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
81
+ content: [
82
+ {
83
+ type: "text",
84
+ text: JSON.stringify(withPersistWarning(result, persisted), null, 2),
85
+ },
86
+ ],
53
87
  };
54
88
  }
55
89
  catch (err) {
@@ -86,9 +120,14 @@ export function registerTools(server, scout) {
86
120
  splitRatio,
87
121
  }));
88
122
  scout.saveResults([...result.quickWins, ...result.biggerBets]);
89
- await scout.checkpoint();
123
+ const persisted = await scout.checkpoint();
90
124
  return {
91
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
125
+ content: [
126
+ {
127
+ type: "text",
128
+ text: JSON.stringify(withPersistWarning(result, persisted), null, 2),
129
+ },
130
+ ],
92
131
  };
93
132
  }
94
133
  catch (err) {
@@ -124,6 +163,21 @@ export function registerTools(server, scout) {
124
163
  content: [{ type: "text", text: JSON.stringify(preferences, null, 2) }],
125
164
  };
126
165
  });
166
+ server.tool("sync", "Reconcile tracked open PRs against GitHub — mark merged/closed and recompute repo scores. Cheap; good for a daily startup call.", async () => {
167
+ try {
168
+ const result = await withTimeout(scout.syncOpenPRs());
169
+ return {
170
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
171
+ };
172
+ }
173
+ catch (err) {
174
+ const msg = err instanceof Error ? err.message : String(err);
175
+ return {
176
+ content: [{ type: "text", text: `Error: ${msg}` }],
177
+ isError: true,
178
+ };
179
+ }
180
+ });
127
181
  server.tool("skip", "Skip, unskip, list, or clear skipped issues. Skipped issues are excluded from future searches and auto-expire after 90 days.", {
128
182
  action: z
129
183
  .enum(["add", "remove", "list", "clear"])
@@ -156,7 +210,9 @@ export function registerTools(server, scout) {
156
210
  const count = scout.getSkippedIssues().length;
157
211
  scout.clearSkippedIssues();
158
212
  const synced = await scout.checkpoint();
159
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
213
+ const syncNote = synced
214
+ ? ""
215
+ : " (warning: failed to persist to disk)";
160
216
  return {
161
217
  content: [
162
218
  {
@@ -174,7 +230,9 @@ export function registerTools(server, scout) {
174
230
  .some((s) => s.url === issueUrl);
175
231
  scout.unskipIssue(issueUrl);
176
232
  const synced = await scout.checkpoint();
177
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
233
+ const syncNote = synced
234
+ ? ""
235
+ : " (warning: failed to persist to disk)";
178
236
  return {
179
237
  content: [
180
238
  {
@@ -187,6 +245,11 @@ export function registerTools(server, scout) {
187
245
  };
188
246
  }
189
247
  // action === "add"
248
+ // Same validation as the CLI's runSkip: skip matching is exact-URL,
249
+ // so junk or near-miss URLs would be stored but never match anything.
250
+ // The throw is caught below and surfaced as isError.
251
+ validateUrl(issueUrl);
252
+ validateGitHubUrl(issueUrl, ISSUE_URL_PATTERN, "issue");
190
253
  const alreadySkipped = scout
191
254
  .getSkippedIssues()
192
255
  .some((s) => s.url === issueUrl);
@@ -209,7 +272,7 @@ export function registerTools(server, scout) {
209
272
  : undefined;
210
273
  scout.skipIssue(issueUrl, metadata);
211
274
  const synced = await scout.checkpoint();
212
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
275
+ const syncNote = synced ? "" : " (warning: failed to persist to disk)";
213
276
  return {
214
277
  content: [{ type: "text", text: `Skipped: ${issueUrl}${syncNote}` }],
215
278
  };
@@ -222,118 +285,42 @@ export function registerTools(server, scout) {
222
285
  };
223
286
  }
224
287
  });
225
- const VALID_KEYS = Object.keys(ScoutPreferencesSchema.shape);
226
- const ARRAY_KEYS = new Set([
227
- "languages",
228
- "labels",
229
- "projectCategories",
230
- "excludeRepos",
231
- "excludeOrgs",
232
- "aiPolicyBlocklist",
233
- "scope",
234
- "defaultStrategy",
235
- ]);
236
- const NUMBER_KEYS = new Set([
237
- "minStars",
238
- "maxIssueAgeDays",
239
- "minRepoScoreThreshold",
240
- "interPhaseDelayMs",
241
- "broadPhaseDelayMs",
242
- "skipBroadWhenSufficientResults",
243
- "featuresAnchorThreshold",
244
- "featuresSplitRatio",
245
- ]);
246
- const BOOLEAN_KEYS = new Set(["includeDocIssues"]);
247
288
  server.tool("config-set", "Update an oss-scout configuration preference", {
248
289
  key: z
249
290
  .string()
250
- .describe("Preference key to update (e.g. languages, minStars, excludeRepos)"),
291
+ .describe(`Preference key to update. One of: ${SORTED_PREFERENCE_KEYS.join(", ")}`),
251
292
  value: z
252
293
  .string()
253
- .describe("New value — comma-separated for arrays, plain string for scalars"),
294
+ .describe('New value. Comma-separated for arrays; prefix with + to append or - to remove items (e.g. "+spam/repo"). Plain string/number/boolean for scalars.'),
254
295
  }, async ({ key, value }) => {
255
- if (!VALID_KEYS.includes(key)) {
256
- return {
257
- content: [
258
- {
259
- type: "text",
260
- text: `Unknown config key: "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
261
- },
262
- ],
263
- isError: true,
264
- };
265
- }
266
- let parsed;
267
- if (ARRAY_KEYS.has(key)) {
268
- // Accept comma-separated strings → arrays
269
- try {
270
- parsed = JSON.parse(value);
271
- if (!Array.isArray(parsed)) {
272
- parsed = value
273
- .split(",")
274
- .map((s) => s.trim())
275
- .filter(Boolean);
276
- }
277
- }
278
- catch {
279
- parsed = value
280
- .split(",")
281
- .map((s) => s.trim())
282
- .filter(Boolean);
283
- }
284
- }
285
- else if (NUMBER_KEYS.has(key)) {
286
- const num = Number(value);
287
- if (isNaN(num)) {
288
- return {
289
- content: [
290
- { type: "text", text: `Invalid number for "${key}": "${value}"` },
291
- ],
292
- isError: true,
293
- };
294
- }
295
- parsed = num;
296
- }
297
- else if (BOOLEAN_KEYS.has(key)) {
298
- const lower = value.toLowerCase();
299
- if (lower === "true" || lower === "yes")
300
- parsed = true;
301
- else if (lower === "false" || lower === "no")
302
- parsed = false;
303
- else {
304
- return {
305
- content: [
306
- {
307
- type: "text",
308
- text: `Invalid boolean for "${key}": "${value}". Use true/false or yes/no.`,
309
- },
310
- ],
311
- isError: true,
312
- };
313
- }
314
- }
315
- else {
316
- // String or enum fields — pass through as-is
317
- parsed = value;
296
+ // Parse + validate via the shared field map so the CLI and MCP stay in
297
+ // lockstep, including the +/- array syntax and the scope special case
298
+ // (#153). applyPreferenceField throws ValidationError on a bad key/value.
299
+ let validated;
300
+ try {
301
+ validated = applyPreferenceField(scout.getPreferences(), key, value);
318
302
  }
319
- const currentPrefs = scout.getPreferences();
320
- const candidate = { ...currentPrefs, [key]: parsed };
321
- // Validate the full preferences object with Zod
322
- const result = ScoutPreferencesSchema.safeParse(candidate);
323
- if (!result.success) {
324
- const issues = result.error.issues
325
- .map((i) => `${i.path.join(".")}: ${i.message}`)
326
- .join("; ");
303
+ catch (err) {
304
+ const msg = err instanceof Error ? err.message : String(err);
327
305
  return {
328
- content: [{ type: "text", text: `Validation error: ${issues}` }],
306
+ content: [{ type: "text", text: msg }],
329
307
  isError: true,
330
308
  };
331
309
  }
332
- scout.updatePreferences({ [key]: parsed });
333
- await scout.checkpoint();
310
+ // Apply only the changed key; validation above ran against the full
311
+ // merged object so cross-field rules still hold.
312
+ scout.updatePreferences({
313
+ [key]: validated[key],
314
+ });
315
+ const persisted = await scout.checkpoint();
334
316
  const updated = scout.getPreferences();
335
317
  return {
336
- content: [{ type: "text", text: JSON.stringify(updated, null, 2) }],
318
+ content: [
319
+ {
320
+ type: "text",
321
+ text: JSON.stringify(withPersistWarning(updated, persisted), null, 2),
322
+ },
323
+ ],
337
324
  };
338
325
  });
339
326
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/mcp",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "MCP server for oss-scout — exposes issue discovery and vetting as MCP tools",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "dependencies": {
19
19
  "@modelcontextprotocol/sdk": "^1.27.1",
20
20
  "zod": "^4.3.6",
21
- "@oss-scout/core": "^0.11.0"
21
+ "@oss-scout/core": "^1.1.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^25.5.0",
@@ -46,7 +46,7 @@
46
46
  },
47
47
  "scripts": {
48
48
  "build": "tsc",
49
- "bundle": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/mcp-server.bundle.cjs",
49
+ "bundle": "esbuild src/bin.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/mcp-server.bundle.cjs",
50
50
  "test": "vitest run",
51
51
  "test:watch": "vitest",
52
52
  "test:coverage": "vitest run --coverage",