@oss-scout/mcp 0.7.0 → 0.9.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,8 @@
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;
4
6
  function withTimeout(promise, ms = TOOL_TIMEOUT_MS) {
5
7
  let timeoutId;
6
8
  const timer = new Promise((_, reject) => {
@@ -26,7 +28,13 @@ export function registerTools(server, scout) {
26
28
  .array(z.string())
27
29
  .optional()
28
30
  .describe("Soft-boost ranking for candidates in one of these `owner/repo` slugs. Stronger weight than language match (#1244). Not a filter; only reorders."),
29
- }, async ({ maxResults, strategies, preferLanguages, preferRepos }) => {
31
+ diversityRatio: z
32
+ .number()
33
+ .min(0)
34
+ .max(1)
35
+ .optional()
36
+ .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."),
37
+ }, async ({ maxResults, strategies, preferLanguages, preferRepos, diversityRatio, }) => {
30
38
  try {
31
39
  const parsedStrategies = strategies
32
40
  ? strategies
@@ -38,6 +46,11 @@ export function registerTools(server, scout) {
38
46
  strategies: parsedStrategies,
39
47
  preferLanguages,
40
48
  preferRepos,
49
+ diversityRatio,
50
+ // No fixed inter-phase sleeps in the request/response MCP context;
51
+ // the budget tracker still paces the GitHub calls (#143)
52
+ interPhaseDelayMs: 0,
53
+ broadPhaseDelayMs: 0,
41
54
  }));
42
55
  scout.saveResults(result.candidates);
43
56
  await scout.checkpoint();
@@ -117,6 +130,21 @@ export function registerTools(server, scout) {
117
130
  content: [{ type: "text", text: JSON.stringify(preferences, null, 2) }],
118
131
  };
119
132
  });
133
+ server.tool("sync", "Reconcile tracked open PRs against GitHub — mark merged/closed and recompute repo scores. Cheap; good for a daily startup call.", async () => {
134
+ try {
135
+ const result = await withTimeout(scout.syncOpenPRs());
136
+ return {
137
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
138
+ };
139
+ }
140
+ catch (err) {
141
+ const msg = err instanceof Error ? err.message : String(err);
142
+ return {
143
+ content: [{ type: "text", text: `Error: ${msg}` }],
144
+ isError: true,
145
+ };
146
+ }
147
+ });
120
148
  server.tool("skip", "Skip, unskip, list, or clear skipped issues. Skipped issues are excluded from future searches and auto-expire after 90 days.", {
121
149
  action: z
122
150
  .enum(["add", "remove", "list", "clear"])
@@ -149,7 +177,9 @@ export function registerTools(server, scout) {
149
177
  const count = scout.getSkippedIssues().length;
150
178
  scout.clearSkippedIssues();
151
179
  const synced = await scout.checkpoint();
152
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
180
+ const syncNote = synced
181
+ ? ""
182
+ : " (warning: failed to persist to disk)";
153
183
  return {
154
184
  content: [
155
185
  {
@@ -167,7 +197,9 @@ export function registerTools(server, scout) {
167
197
  .some((s) => s.url === issueUrl);
168
198
  scout.unskipIssue(issueUrl);
169
199
  const synced = await scout.checkpoint();
170
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
200
+ const syncNote = synced
201
+ ? ""
202
+ : " (warning: failed to persist to disk)";
171
203
  return {
172
204
  content: [
173
205
  {
@@ -180,6 +212,11 @@ export function registerTools(server, scout) {
180
212
  };
181
213
  }
182
214
  // action === "add"
215
+ // Same validation as the CLI's runSkip: skip matching is exact-URL,
216
+ // so junk or near-miss URLs would be stored but never match anything.
217
+ // The throw is caught below and surfaced as isError.
218
+ validateUrl(issueUrl);
219
+ validateGitHubUrl(issueUrl, ISSUE_URL_PATTERN, "issue");
183
220
  const alreadySkipped = scout
184
221
  .getSkippedIssues()
185
222
  .some((s) => s.url === issueUrl);
@@ -202,7 +239,7 @@ export function registerTools(server, scout) {
202
239
  : undefined;
203
240
  scout.skipIssue(issueUrl, metadata);
204
241
  const synced = await scout.checkpoint();
205
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
242
+ const syncNote = synced ? "" : " (warning: failed to persist to disk)";
206
243
  return {
207
244
  content: [{ type: "text", text: `Skipped: ${issueUrl}${syncNote}` }],
208
245
  };
@@ -215,114 +252,33 @@ export function registerTools(server, scout) {
215
252
  };
216
253
  }
217
254
  });
218
- const VALID_KEYS = Object.keys(ScoutPreferencesSchema.shape);
219
- const ARRAY_KEYS = new Set([
220
- "languages",
221
- "labels",
222
- "projectCategories",
223
- "excludeRepos",
224
- "excludeOrgs",
225
- "aiPolicyBlocklist",
226
- "scope",
227
- "defaultStrategy",
228
- ]);
229
- const NUMBER_KEYS = new Set([
230
- "minStars",
231
- "maxIssueAgeDays",
232
- "minRepoScoreThreshold",
233
- "interPhaseDelayMs",
234
- "broadPhaseDelayMs",
235
- "skipBroadWhenSufficientResults",
236
- "featuresAnchorThreshold",
237
- "featuresSplitRatio",
238
- ]);
239
- const BOOLEAN_KEYS = new Set(["includeDocIssues"]);
240
255
  server.tool("config-set", "Update an oss-scout configuration preference", {
241
256
  key: z
242
257
  .string()
243
- .describe("Preference key to update (e.g. languages, minStars, excludeRepos)"),
258
+ .describe(`Preference key to update. One of: ${SORTED_PREFERENCE_KEYS.join(", ")}`),
244
259
  value: z
245
260
  .string()
246
- .describe("New value — comma-separated for arrays, plain string for scalars"),
261
+ .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.'),
247
262
  }, async ({ key, value }) => {
248
- if (!VALID_KEYS.includes(key)) {
249
- return {
250
- content: [
251
- {
252
- type: "text",
253
- text: `Unknown config key: "${key}". Valid keys: ${VALID_KEYS.join(", ")}`,
254
- },
255
- ],
256
- isError: true,
257
- };
258
- }
259
- let parsed;
260
- if (ARRAY_KEYS.has(key)) {
261
- // Accept comma-separated strings → arrays
262
- try {
263
- parsed = JSON.parse(value);
264
- if (!Array.isArray(parsed)) {
265
- parsed = value
266
- .split(",")
267
- .map((s) => s.trim())
268
- .filter(Boolean);
269
- }
270
- }
271
- catch {
272
- parsed = value
273
- .split(",")
274
- .map((s) => s.trim())
275
- .filter(Boolean);
276
- }
277
- }
278
- else if (NUMBER_KEYS.has(key)) {
279
- const num = Number(value);
280
- if (isNaN(num)) {
281
- return {
282
- content: [
283
- { type: "text", text: `Invalid number for "${key}": "${value}"` },
284
- ],
285
- isError: true,
286
- };
287
- }
288
- parsed = num;
289
- }
290
- else if (BOOLEAN_KEYS.has(key)) {
291
- const lower = value.toLowerCase();
292
- if (lower === "true" || lower === "yes")
293
- parsed = true;
294
- else if (lower === "false" || lower === "no")
295
- parsed = false;
296
- else {
297
- return {
298
- content: [
299
- {
300
- type: "text",
301
- text: `Invalid boolean for "${key}": "${value}". Use true/false or yes/no.`,
302
- },
303
- ],
304
- isError: true,
305
- };
306
- }
307
- }
308
- else {
309
- // String or enum fields — pass through as-is
310
- parsed = value;
263
+ // Parse + validate via the shared field map so the CLI and MCP stay in
264
+ // lockstep, including the +/- array syntax and the scope special case
265
+ // (#153). applyPreferenceField throws ValidationError on a bad key/value.
266
+ let validated;
267
+ try {
268
+ validated = applyPreferenceField(scout.getPreferences(), key, value);
311
269
  }
312
- const currentPrefs = scout.getPreferences();
313
- const candidate = { ...currentPrefs, [key]: parsed };
314
- // Validate the full preferences object with Zod
315
- const result = ScoutPreferencesSchema.safeParse(candidate);
316
- if (!result.success) {
317
- const issues = result.error.issues
318
- .map((i) => `${i.path.join(".")}: ${i.message}`)
319
- .join("; ");
270
+ catch (err) {
271
+ const msg = err instanceof Error ? err.message : String(err);
320
272
  return {
321
- content: [{ type: "text", text: `Validation error: ${issues}` }],
273
+ content: [{ type: "text", text: msg }],
322
274
  isError: true,
323
275
  };
324
276
  }
325
- scout.updatePreferences({ [key]: parsed });
277
+ // Apply only the changed key; validation above ran against the full
278
+ // merged object so cross-field rules still hold.
279
+ scout.updatePreferences({
280
+ [key]: validated[key],
281
+ });
326
282
  await scout.checkpoint();
327
283
  const updated = scout.getPreferences();
328
284
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/mcp",
3
- "version": "0.7.0",
3
+ "version": "0.9.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.10.0"
21
+ "@oss-scout/core": "^1.0.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",