fmea-api-mcp-server 1.1.1 → 1.1.3

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/index.js CHANGED
@@ -9,6 +9,54 @@ import { fileURLToPath } from "url";
9
9
  import { getSynonyms } from "./synonyms.js";
10
10
  const __filename = fileURLToPath(import.meta.url);
11
11
  const __dirname = path.dirname(__filename);
12
+ /**
13
+ * Normalize token to singular form for better matching.
14
+ * Handles common English plural patterns.
15
+ */
16
+ function normalizeToken(token) {
17
+ const normalized = [token];
18
+ // Simple pluralization rules (token -> singular or singular -> plural)
19
+ const rules = [
20
+ // Remove 's' for common plurals
21
+ [/([a-z]+)s$/, '$1'],
22
+ // Remove 'es' for words ending with s, x, z, ch, sh
23
+ [/([a-z]+)es$/, '$1'],
24
+ // Remove 'ies' and replace with 'y'
25
+ [/([a-z]+)ies$/, '$1y'],
26
+ // Remove 'ves' and replace with 'f' (leaf -> leaves)
27
+ [/([a-z]+)ves$/, '$1f'],
28
+ // Remove 'men' and replace with 'man' (woman -> women)
29
+ [/([a-z]+)men$/, '$1man'],
30
+ ];
31
+ // Generate singular variants
32
+ for (const [pattern, replacement] of rules) {
33
+ if (pattern.test(token)) {
34
+ const singular = token.replace(pattern, replacement);
35
+ if (singular !== token && singular.length > 2) {
36
+ normalized.push(singular);
37
+ }
38
+ break;
39
+ }
40
+ }
41
+ // Add plural variant (append 's' if not ending with 's')
42
+ if (!token.endsWith('s') && token.length > 2) {
43
+ normalized.push(token + 's');
44
+ }
45
+ return normalized;
46
+ }
47
+ /**
48
+ * Expand tokens to include singular/plural variants
49
+ */
50
+ function expandTokenVariants(tokens) {
51
+ const variants = new Set(tokens);
52
+ for (const token of tokens) {
53
+ const normalized = normalizeToken(token);
54
+ for (const variant of normalized) {
55
+ variants.add(variant);
56
+ }
57
+ }
58
+ return variants;
59
+ }
12
60
  // Directory where endpoint definitions are stored.
13
61
  // Priority:
14
62
  // 1. Environment variable ENDPOINTS_DIR
@@ -120,7 +168,7 @@ class ApiDocsServer {
120
168
  description: "Page number for pagination (default: 1).",
121
169
  },
122
170
  },
123
- required: ["query"],
171
+ required: [],
124
172
  },
125
173
  },
126
174
  {
@@ -146,10 +194,21 @@ class ApiDocsServer {
146
194
  });
147
195
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
148
196
  if (request.params.name === "search_apis") {
149
- const query = String(request.params.arguments?.query).toLowerCase();
197
+ const query = request.params.arguments?.query ? String(request.params.arguments?.query).toLowerCase() : "";
150
198
  const method = request.params.arguments?.method ? String(request.params.arguments?.method).toUpperCase() : undefined;
151
199
  const version = request.params.arguments?.version ? String(request.params.arguments?.version).toLowerCase() : undefined;
152
200
  const page = request.params.arguments?.page ? Number(request.params.arguments?.page) : 1;
201
+ if (!query) {
202
+ const categories = await this.listApiCategories();
203
+ return {
204
+ content: [
205
+ {
206
+ type: "text",
207
+ text: JSON.stringify(categories, null, 2),
208
+ },
209
+ ],
210
+ };
211
+ }
153
212
  const results = await this.searchInFiles(query, method, version, page);
154
213
  return {
155
214
  content: [
@@ -211,6 +270,38 @@ class ApiDocsServer {
211
270
  }
212
271
  return results;
213
272
  }
273
+ // Helper to list API categories (directories) for exploration
274
+ async listApiCategories() {
275
+ const categories = [];
276
+ try {
277
+ const topLevel = await fs.readdir(ENDPOINTS_DIR);
278
+ for (const versionDir of topLevel) {
279
+ const versionPath = path.join(ENDPOINTS_DIR, versionDir);
280
+ const stat = await fs.stat(versionPath);
281
+ if (stat.isDirectory() && !versionDir.startsWith('.')) {
282
+ // Look one level deeper for domains (e.g. v1/projects)
283
+ const domains = await fs.readdir(versionPath);
284
+ for (const domain of domains) {
285
+ const domainPath = path.join(versionPath, domain);
286
+ const domainStat = await fs.stat(domainPath);
287
+ if (domainStat.isDirectory()) {
288
+ categories.push({
289
+ category: `${versionDir}/${domain}`,
290
+ description: `API endpoints for ${domain} (${versionDir})`
291
+ });
292
+ }
293
+ }
294
+ }
295
+ }
296
+ }
297
+ catch (e) {
298
+ console.error("Error listing categories:", e);
299
+ }
300
+ return {
301
+ categories: categories,
302
+ message: "Use 'search_apis' with a query to find specific endpoints, or 'get_api_details' to see full schemas."
303
+ };
304
+ }
214
305
  // Smart search helper with BM25 scoring, Synonyms, and AND logic
215
306
  async searchInFiles(query, filterMethod, filterVersion, page = 1) {
216
307
  const files = await this.getAllFiles(ENDPOINTS_DIR);
@@ -243,11 +334,13 @@ class ApiDocsServer {
243
334
  (endpoint.path || "").toLowerCase()
244
335
  ].join(" ");
245
336
  const tokens = searchableText.split(/\s+/).filter(t => t.length > 0);
337
+ // Expand document tokens with singular/plural variants
338
+ const expandedTokens = Array.from(expandTokenVariants(tokens));
246
339
  documents.push({
247
340
  file: fileName,
248
341
  ...endpoint,
249
- tokens, // For BM25 calculation
250
- docLength: tokens.length
342
+ tokens: expandedTokens, // Expanded tokens for BM25 calculation
343
+ docLength: expandedTokens.length
251
344
  });
252
345
  }
253
346
  }
@@ -279,12 +372,15 @@ class ApiDocsServer {
279
372
  meta: { total: totalFound, page: currentPage, totalPages: totalPages }
280
373
  };
281
374
  }
282
- // Filter Documents: AND Logic with Synonym Expansion
283
- // Every query token (or one of its synonyms) MUST be present in the document
375
+ // Filter Documents: AND Logic with Synonym Expansion + Plural/Singular Normalization
376
+ // Every query token (or one of its synonyms/plurals) MUST be present in the document
284
377
  const filteredDocs = documents.filter(doc => {
285
378
  return rawQueryTokens.every(qToken => {
379
+ // Get synonyms first
286
380
  const synonyms = getSynonyms(qToken);
287
- return synonyms.some((syn) => doc.tokens.includes(syn));
381
+ // Expand with plural/singular variants
382
+ const expandedQuery = Array.from(expandTokenVariants(synonyms));
383
+ return expandedQuery.some((variant) => doc.tokens.includes(variant));
288
384
  });
289
385
  });
290
386
  if (filteredDocs.length === 0) {
@@ -299,13 +395,18 @@ class ApiDocsServer {
299
395
  const k1 = 1.2;
300
396
  const b = 0.75;
301
397
  const avgdl = documents.reduce((acc, doc) => acc + doc.docLength, 0) / totalFound;
302
- // Calculate IDF (using full corpus) for *expanded* tokens?
303
- // Complexity: simple approach -> Calculate IDF for the specific matching token in the doc for scoring.
304
- // If multiple synonyms match, take the max score or sum? Sum is risky (double count).
305
- // We will iterate query tokens, find the *best matching synonym* in the doc, and score that.
306
- // Pre-calculate IDF for all potential terms in query (raw + synonyms)
398
+ // Calculate IDF (using full corpus) for *expanded* tokens (synonyms + plurals)
399
+ // We expand query terms with both synonyms and plural/singular variants
307
400
  const allQueryTerms = new Set();
308
- rawQueryTokens.forEach(t => getSynonyms(t).forEach((s) => allQueryTerms.add(s)));
401
+ rawQueryTokens.forEach(t => {
402
+ const synonyms = getSynonyms(t);
403
+ synonyms.forEach((s) => {
404
+ allQueryTerms.add(s);
405
+ // Also add plural/singular variants
406
+ const variants = normalizeToken(s);
407
+ variants.forEach(v => allQueryTerms.add(v));
408
+ });
409
+ });
309
410
  const idf = {};
310
411
  for (const term of allQueryTerms) {
311
412
  let n_q = 0;
@@ -319,13 +420,12 @@ class ApiDocsServer {
319
420
  let scoredDocs = filteredDocs.map(doc => {
320
421
  let score = 0;
321
422
  for (const qToken of rawQueryTokens) {
322
- // Find which synonyms of qToken are present in this doc
423
+ // Find which synonyms + plural variants of qToken are present in this doc
323
424
  const synonyms = getSynonyms(qToken);
324
- const presentSynonyms = synonyms.filter((syn) => doc.tokens.includes(syn));
325
- // If multiple synonyms match (e.g. 'find' and 'get' both in doc), we should probably
326
- // just take the best one or sum them with saturation.
327
- // Simplified: Sum them up (assuming they add more relevance).
328
- for (const term of presentSynonyms) {
425
+ const expandedQuery = Array.from(expandTokenVariants(synonyms));
426
+ const presentTerms = expandedQuery.filter((term) => doc.tokens.includes(term));
427
+ // Sum up scores for all matching terms (synonyms + plurals)
428
+ for (const term of presentTerms) {
329
429
  const f_q = doc.tokens.filter((t) => t === term).length;
330
430
  const numerator = idf[term] * f_q * (k1 + 1);
331
431
  const denominator = f_q + k1 * (1 - b + b * (doc.docLength / avgdl));
@@ -344,17 +444,17 @@ class ApiDocsServer {
344
444
  const start = (currentPage - 1) * LIMIT;
345
445
  // Slice
346
446
  const slice = scoredDocs.slice(start, start + LIMIT);
347
- // Post-processing: Add warnings for V1 endpoints
447
+ // Post-processing: Add warnings for V1 endpoints AND strip heavy fields
348
448
  const finalResults = await Promise.all(slice.map(async (item) => {
349
- const { score, tokens, docLength, ...rest } = item; // Remove internal props
350
- if (rest.path && rest.path.includes("/v1/")) {
351
- const v2Path = rest.path.replace("/v1/", "/v2/");
352
- const v2Exists = await this.findEndpointInFiles(files, v2Path, rest.method);
353
- if (v2Exists) {
354
- rest.warning = "DEPRECATED: Version v1 is deprecated. Please use v2 endpoint: " + v2Path;
355
- }
449
+ // Deconstruct to remove heavy fields (parameters, requestBody, responses, tags) and internal scoring props
450
+ const { score, tokens, docLength, parameters, requestBody, responses, tags, file, ...lightweightItem } = item;
451
+ if (lightweightItem.path && lightweightItem.path.includes("/v1/")) {
452
+ // Check for V1 Deprecation
453
+ // Always generate a warning for v1 endpoints using the 3-step logic
454
+ // We do this check after determining it is a v1 endpoint
455
+ lightweightItem.warning = await this.generateDeprecationWarning(lightweightItem.path, lightweightItem.method);
356
456
  }
357
- return rest;
457
+ return lightweightItem;
358
458
  }));
359
459
  let warning = undefined;
360
460
  if (totalPages > 1) {
@@ -384,17 +484,15 @@ class ApiDocsServer {
384
484
  continue;
385
485
  }
386
486
  const result = {
387
- sourceFile: path.relative(ENDPOINTS_DIR, filePath),
388
487
  ...endpoint
389
488
  };
390
489
  // Check for V1 Deprecation
391
490
  if (apiPath.includes("/v1/")) {
392
- const v2Path = apiPath.replace("/v1/", "/v2/");
393
- const v2Exists = await this.findEndpointInFiles(files, v2Path, method);
394
- if (v2Exists) {
395
- // Inject a top-level deprecation warning in the details
396
- result.deprecation_warning = `NOTICE: This v1 endpoint is deprecated. A newer version (v2) exists at ${v2Path}`;
397
- }
491
+ // Check for V1 Deprecation
492
+ // Always generate a warning for v1 endpoints using the 3-step logic
493
+ // We do this check regardless of whether a direct v2 exists or not,
494
+ // because generateDeprecationWarning handles all cases.
495
+ result.warning = await this.generateDeprecationWarning(apiPath, method);
398
496
  }
399
497
  return result;
400
498
  }
@@ -429,6 +527,43 @@ class ApiDocsServer {
429
527
  }
430
528
  return false;
431
529
  }
530
+ // 3-Step Intelligent Warning Logic
531
+ async generateDeprecationWarning(v1Path, method) {
532
+ const files = await this.getAllFiles(ENDPOINTS_DIR);
533
+ // Step 1: Direct Match (v1 -> v2)
534
+ const v2Path = v1Path.replace("/v1/", "/v2/");
535
+ const v2Exists = await this.findEndpointInFiles(files, v2Path, method);
536
+ if (v2Exists) {
537
+ return `DEPRECATED: Version v1 is deprecated. Please use v2 endpoint: ${v2Path}`;
538
+ }
539
+ // Step 2: Domain Hint (e.g. /api/v1/auth/login -> Check /api/v2/auth/)
540
+ // Extract domain: /api/v1/projects/... -> projects, /api/v1/auth/... -> auth
541
+ const match = v1Path.match(/\/api\/v1\/([^/]+)/);
542
+ if (match && match[1]) {
543
+ const domain = match[1];
544
+ const v2DomainPathStart = `/api/v2/${domain}`;
545
+ // Check if any v2 endpoint exists in this domain
546
+ let domainExists = false;
547
+ for (const filePath of files) {
548
+ try {
549
+ const content = await fs.readFile(filePath, "utf-8");
550
+ const json = JSON.parse(content);
551
+ if (json.endpoints && Array.isArray(json.endpoints)) {
552
+ if (json.endpoints.some((ep) => ep.path && ep.path.startsWith(v2DomainPathStart))) {
553
+ domainExists = true;
554
+ break;
555
+ }
556
+ }
557
+ }
558
+ catch (e) { }
559
+ }
560
+ if (domainExists) {
561
+ return `LEGACY: Direct v2 replacement not found, but newer '${domain}' related features exist in v2. Please search for '${domain}' in v2.`;
562
+ }
563
+ }
564
+ // Step 3: General Legacy Warning
565
+ return "LEGACY: This v1 endpoint is deprecated and may be removed in the future.";
566
+ }
432
567
  async run() {
433
568
  const transport = new StdioServerTransport();
434
569
  await this.server.connect(transport);
package/dist/synonyms.js CHANGED
@@ -1,9 +1,11 @@
1
1
  export const SYNONYM_GROUPS = {
2
2
  // Read / Retrieve
3
- "get": ["fetch", "retrieve", "read", "load", "find", "search", "query", "list"],
4
- "find": ["get", "search", "retrieve", "lookup"],
5
- "search": ["find", "get", "query", "lookup"],
6
- "list": ["get", "all", "collection"],
3
+ "get": ["fetch", "retrieve", "read", "load", "find", "search", "query", "list", "show"],
4
+ "find": ["get", "search", "retrieve", "lookup", "show"],
5
+ "search": ["find", "get", "query", "lookup", "show"],
6
+ "show": ["get", "display", "view", "fetch", "find"],
7
+ "list": ["get", "all", "collection", "show", "summary"],
8
+ "summary": ["list", "all", "overview", "collection"],
7
9
  // Create
8
10
  "create": ["add", "insert", "make", "new", "post", "generate"],
9
11
  "add": ["create", "insert", "append", "attach"],
@@ -18,6 +18,16 @@ endpoints/
18
18
  └── ...
19
19
  ```
20
20
 
21
+ ### Classification Rules
22
+
23
+ To prevent ambiguity, follow these classification principles:
24
+
25
+ - **Domain Specific**: If an endpoint operates *within the context* of a specific resource (e.g., "Export THIS resource's sub-data"), place it under that resource's directory.
26
+ - Example: `/api/v2/main-resources/{id}/sub-feature` -> `endpoints/v2/main-resources/sub-feature.json`
27
+
28
+ - **Global / Utility**: If an endpoint is a generic utility or operates independently of other resources (e.g., "Process ANY file"), create a dedicated top-level resource directory.
29
+ - Example: `/api/v2/standalone-feature` -> `endpoints/v2/standalone-feature/core.json`
30
+
21
31
  ### Rules
22
32
 
23
33
  1. **Directory Required**: Every resource (e.g., `projects`, `users`) MUST be a directory, even if it only contains one file.
@@ -27,7 +37,15 @@ endpoints/
27
37
  - `category`: String (Resource category name)
28
38
  - `version`: String ("v1" or "v2")
29
39
  - `description`: String (Resource description)
30
- - `endpoints`: Array of endpoint objects.
40
+ - `endpoints`: Array of endpoint objects. Each object MUST contain:
41
+ - `path`: String (URI path)
42
+ - `method`: String (HTTP method)
43
+ - `summary`: String (Short summary)
44
+ - `description`: String (Detailed description)
45
+ - `tags`: Array of Strings
46
+ - `operationId`: String (Unique identifier)
47
+ - `parameters`: Array (Optional parameters)
48
+ - `responses`: Object (Response definitions)
31
49
 
32
50
  ### Example
33
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fmea-api-mcp-server",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "description": "MCP server for serving API documentation from endpoints directory",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",