@oss-scout/mcp 0.8.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) => {
@@ -45,6 +47,10 @@ export function registerTools(server, scout) {
45
47
  preferLanguages,
46
48
  preferRepos,
47
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,
48
54
  }));
49
55
  scout.saveResults(result.candidates);
50
56
  await scout.checkpoint();
@@ -124,6 +130,21 @@ export function registerTools(server, scout) {
124
130
  content: [{ type: "text", text: JSON.stringify(preferences, null, 2) }],
125
131
  };
126
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
+ });
127
148
  server.tool("skip", "Skip, unskip, list, or clear skipped issues. Skipped issues are excluded from future searches and auto-expire after 90 days.", {
128
149
  action: z
129
150
  .enum(["add", "remove", "list", "clear"])
@@ -156,7 +177,9 @@ export function registerTools(server, scout) {
156
177
  const count = scout.getSkippedIssues().length;
157
178
  scout.clearSkippedIssues();
158
179
  const synced = await scout.checkpoint();
159
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
180
+ const syncNote = synced
181
+ ? ""
182
+ : " (warning: failed to persist to disk)";
160
183
  return {
161
184
  content: [
162
185
  {
@@ -174,7 +197,9 @@ export function registerTools(server, scout) {
174
197
  .some((s) => s.url === issueUrl);
175
198
  scout.unskipIssue(issueUrl);
176
199
  const synced = await scout.checkpoint();
177
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
200
+ const syncNote = synced
201
+ ? ""
202
+ : " (warning: failed to persist to disk)";
178
203
  return {
179
204
  content: [
180
205
  {
@@ -187,6 +212,11 @@ export function registerTools(server, scout) {
187
212
  };
188
213
  }
189
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");
190
220
  const alreadySkipped = scout
191
221
  .getSkippedIssues()
192
222
  .some((s) => s.url === issueUrl);
@@ -209,7 +239,7 @@ export function registerTools(server, scout) {
209
239
  : undefined;
210
240
  scout.skipIssue(issueUrl, metadata);
211
241
  const synced = await scout.checkpoint();
212
- const syncNote = synced ? "" : " (saved locally, gist sync failed)";
242
+ const syncNote = synced ? "" : " (warning: failed to persist to disk)";
213
243
  return {
214
244
  content: [{ type: "text", text: `Skipped: ${issueUrl}${syncNote}` }],
215
245
  };
@@ -222,114 +252,33 @@ export function registerTools(server, scout) {
222
252
  };
223
253
  }
224
254
  });
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
255
  server.tool("config-set", "Update an oss-scout configuration preference", {
248
256
  key: z
249
257
  .string()
250
- .describe("Preference key to update (e.g. languages, minStars, excludeRepos)"),
258
+ .describe(`Preference key to update. One of: ${SORTED_PREFERENCE_KEYS.join(", ")}`),
251
259
  value: z
252
260
  .string()
253
- .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.'),
254
262
  }, 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;
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);
318
269
  }
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("; ");
270
+ catch (err) {
271
+ const msg = err instanceof Error ? err.message : String(err);
327
272
  return {
328
- content: [{ type: "text", text: `Validation error: ${issues}` }],
273
+ content: [{ type: "text", text: msg }],
329
274
  isError: true,
330
275
  };
331
276
  }
332
- 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
+ });
333
282
  await scout.checkpoint();
334
283
  const updated = scout.getPreferences();
335
284
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oss-scout/mcp",
3
- "version": "0.8.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.11.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",