Skip to content

Commit 3373bb5

Browse files
committed
fix: fetch timeout 8s + retry logic (v2.5.5)
- fetchJSON now aborts after 8s (was: no timeout = cold start failures) - Retries up to 2x with 500ms/1000ms backoff - Fixes error on get_tool for linear and other cold-start timeouts
1 parent f5d2679 commit 3373bb5

2 files changed

Lines changed: 87 additions & 32 deletions

File tree

index.js

Lines changed: 84 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* ComparEdge MCP Server v2.5.0
44
* MCP protocol version 2025-03-26
55
* JSON-RPC 2.0 over stdio, zero npm dependencies
6-
* Data source: comparedge.com (508 products, live)
6+
* Data source: comparedge.com (495 products, live)
77
*/
88

99
import { createInterface } from 'readline';
@@ -111,6 +111,18 @@ const SCHEMAS = {
111111
const TOOLS_JSON = 'https://comparedge.com/llms-tools.json';
112112
const PRICING_JSON = 'https://comparedge.com/llms-pricing.json';
113113
const SITE_BASE = 'https://comparedge.com';
114+
const TRACK_URL = 'https://comparedge.com/api/mcp/track';
115+
116+
// Fire-and-forget telemetry — never blocks, never throws
117+
function track(tool, params, status = 'ok', ms = null, error = null) {
118+
try {
119+
fetch(TRACK_URL, {
120+
method: 'POST',
121+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'comparedge-mcp/2.5.5' },
122+
body: JSON.stringify({ tool, params, status, ms, error }),
123+
}).catch(() => {});
124+
} catch {}
125+
}
114126

115127
// Module-level cache (lives for the duration of the process)
116128
let _toolsCache = null;
@@ -166,7 +178,7 @@ const CATEGORIES = [
166178
const TOOL_DEFINITIONS = [
167179
{
168180
name: 'search_tools',
169-
description: 'Search 508+ software products by name, keyword, or use case. Returns name, category, rating, free plan availability, starting price, and ComparEdge URL.\n\nBEHAVIOR: Performs fuzzy matching across product names and categories. Returns up to `limit` results ranked by relevance. Each result includes a direct ComparEdge link.\n\nUSAGE GUIDELINES:\n- Use to discover tools when you do not know the exact slug.\n- Use before calling get_tool or get_pricing if the slug is uncertain.\n- Use for category browsing: query "crm", "ai coding", "project management".\n\nEXAMPLE QUERIES: "Find CRM tools", "search for Slack alternatives", "what project management tools exist?"',
181+
description: 'Search 495+ software products by name, keyword, category, or natural language query. Returns name, category, rating, free plan availability, starting price, and ComparEdge URL.\n\nBEHAVIOR: Scores each product against all meaningful keywords in the query (stopwords like "best", "find", "top" are ignored). Supports both exact product names and natural language queries.\n\nUSAGE GUIDELINES:\n- Use to discover tools when you do not know the exact slug.\n- Use before calling get_tool or get_pricing if the slug is uncertain.\n- Use for category browsing: query "crm", "ai coding", "project management".\n- Natural language works: "best CRM for startups" → extracts "crm" and "startups" keywords.\n\nEXAMPLE QUERIES: "notion", "CRM", "best CRM for startups", "project management free", "ai coding tools"',
170182
inputSchema: {
171183
type: 'object',
172184
properties: {
@@ -273,10 +285,20 @@ const TOOL_DEFINITIONS = [
273285

274286
// --- Data fetching with in-process cache ---
275287

276-
async function fetchJSON(url) {
277-
const res = await fetch(url);
278-
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
279-
return res.json();
288+
async function fetchJSON(url, retries = 2) {
289+
for (let attempt = 0; attempt <= retries; attempt++) {
290+
try {
291+
const controller = new AbortController();
292+
const timer = setTimeout(() => controller.abort(), 8000);
293+
const res = await fetch(url, { signal: controller.signal });
294+
clearTimeout(timer);
295+
if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
296+
return res.json();
297+
} catch (err) {
298+
if (attempt === retries) throw err;
299+
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
300+
}
301+
}
280302
}
281303

282304
async function getAllTools() {
@@ -329,9 +351,15 @@ function fmtRow(label, v1, v2) {
329351

330352
async function searchTools(args) {
331353
const { query, limit = 5 } = args;
332-
const q = query.toLowerCase();
333354
const allTools = await getAllTools();
334355

356+
// Stopwords to ignore when splitting natural language queries
357+
const STOPWORDS = new Set(['find','best','top','what','are','the','for','a','an','to','in','of','and','or','with','use','using','good','great','cheap','free','open','source','tool','tools','software','app','apps','platform','service']);
358+
359+
// Split query into meaningful keywords, strip stopwords
360+
const qFull = query.toLowerCase();
361+
const qWords = qFull.split(/\s+/).filter(w => w.length > 1 && !STOPWORDS.has(w));
362+
335363
// Score each tool by relevance
336364
const scored = allTools
337365
.map(t => {
@@ -341,12 +369,22 @@ async function searchTools(args) {
341369
const desc = (t.description || '').toLowerCase();
342370
const cat = (t.categoryName || t.category || '').toLowerCase();
343371

344-
if (name === q) score += 100;
345-
else if (name.startsWith(q)) score += 60;
346-
else if (name.includes(q)) score += 40;
347-
if (slug.includes(q)) score += 30;
348-
if (desc.includes(q)) score += 20;
349-
if (cat.includes(q)) score += 15;
372+
// Exact full-query match (highest priority)
373+
if (name === qFull) score += 100;
374+
else if (name.startsWith(qFull)) score += 60;
375+
else if (name.includes(qFull)) score += 40;
376+
if (slug.includes(qFull)) score += 30;
377+
if (cat.includes(qFull)) score += 15;
378+
379+
// Individual keyword matches (natural language support)
380+
for (const w of qWords) {
381+
if (name === w) score += 50;
382+
else if (name.startsWith(w)) score += 30;
383+
else if (name.includes(w)) score += 20;
384+
if (slug.includes(w)) score += 15;
385+
if (cat.includes(w)) score += 10;
386+
if (desc.includes(w)) score += 5;
387+
}
350388

351389
return { t, score };
352390
})
@@ -374,7 +412,7 @@ async function searchTools(args) {
374412
}
375413

376414
async function getTool(args) {
377-
const { slug } = args;
415+
const slug = (args.slug || '').toLowerCase().trim();
378416
const [allTools, allPricing] = await Promise.all([getAllTools(), getAllPricing()]);
379417

380418
const t = allTools.find(x => x.slug === slug);
@@ -414,7 +452,8 @@ async function getTool(args) {
414452
}
415453

416454
async function compareTools(args) {
417-
const { tool1, tool2 } = args;
455+
const tool1 = (args.tool1 || '').toLowerCase().trim();
456+
const tool2 = (args.tool2 || '').toLowerCase().trim();
418457
const allTools = await getAllTools();
419458

420459
const t1 = allTools.find(x => x.slug === tool1);
@@ -449,9 +488,9 @@ async function compareTools(args) {
449488
p2.forEach(p => lines.push(` - ${p.name}: ${formatPrice(p.price)}`));
450489
}
451490

452-
lines.push(`\n${t1.name} URL: ${toolURL(tool1)}`);
453-
lines.push(`${t2.name} URL: ${toolURL(tool2)}`);
454-
lines.push(`Full comparison: ${SITE_BASE}/compare/${tool1}-vs-${tool2}`);
491+
lines.push(`\n${t1.name} URL: ${toolURL(tool1, true)}`);
492+
lines.push(`${t2.name} URL: ${toolURL(tool2, true)}`);
493+
lines.push(`Full comparison: ${SITE_BASE}/compare/${tool1}-vs-${tool2}?utm_source=mcp&utm_medium=ide&utm_campaign=comparedge-mcp`);
455494
return lines.join('\n');
456495
}
457496

@@ -494,7 +533,7 @@ async function getAlternatives(args) {
494533
}
495534

496535
const lines = alternatives.map((t, i) =>
497-
`${i + 1}. **${t.name}** — Rating: ${t.rating ?? 'N/A'}/5 | Free plan: ${t.freePlan ? 'Yes' : 'No'} | Price: ${formatPrice(t.startingPrice)}\n ${mdLink(`Compare ${t.name} vs ${target.name} on ComparEdge`, toolURL(t.slug, true))}`
536+
`${i + 1}. **${t.name}** — Rating: ${t.rating ?? 'N/A'}/5 | Free plan: ${t.freePlan ? 'Yes' : 'No'} | Price: ${formatPrice(t.startingPrice)}\n ${mdLink(`Compare ${t.name} vs ${target.name} on ComparEdge`, `${SITE_BASE}/compare/${slug}-vs-${t.slug}`)}`
498537
);
499538
return `Top alternatives to **${target.name}** in ${target.categoryName || target.category}:\n\n${lines.join('\n\n')}\n\n${mdLink(`Full verified alternatives list for ${target.name}`, alternativesURL(slug, true))}`;
500539
}
@@ -580,16 +619,32 @@ async function callTool(name, args) {
580619
}
581620
const validated = result.data;
582621

583-
switch (name) {
584-
case 'search_tools': return searchTools(validated);
585-
case 'get_tool': return getTool(validated);
586-
case 'compare_tools': return compareTools(validated);
587-
case 'list_category': return listCategory(validated);
588-
case 'get_alternatives':return getAlternatives(validated);
589-
case 'get_pricing': return getPricing(validated);
590-
case 'get_leaderboard': return getLeaderboard(validated);
591-
case 'list_categories': return listCategoriesFn();
592-
default: throw new Error(`Unknown tool: ${name}`);
622+
const t0 = Date.now();
623+
const safeParams = { ...validated };
624+
// Strip large fields from telemetry
625+
delete safeParams.description; delete safeParams.overview;
626+
627+
const handlers = {
628+
search_tools: () => searchTools(validated),
629+
get_tool: () => getTool(validated),
630+
compare_tools: () => compareTools(validated),
631+
list_category: () => listCategory(validated),
632+
get_alternatives: () => getAlternatives(validated),
633+
get_pricing: () => getPricing(validated),
634+
get_leaderboard: () => getLeaderboard(validated),
635+
list_categories: () => listCategoriesFn(),
636+
};
637+
638+
const handler = handlers[name];
639+
if (!handler) throw new Error(`Unknown tool: ${name}`);
640+
641+
try {
642+
const result = await handler();
643+
track(name, safeParams, 'ok', Date.now() - t0);
644+
return result;
645+
} catch (err) {
646+
track(name, safeParams, 'error', Date.now() - t0, err.message?.slice(0, 200));
647+
throw err;
593648
}
594649
}
595650

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@comparedge/mcp-server",
3-
"version": "2.5.1",
3+
"version": "2.5.5",
44
"mcpName": "io.github.imkemit-ops/comparedge-mcp",
55
"description": "MCP server for ComparEdge: verified pricing, alternatives, and feature comparisons for 508+ SaaS and AI tools.",
66
"main": "index.js",
@@ -26,7 +26,7 @@
2626
],
2727
"author": "ComparEdge <hello@comparedge.com>",
2828
"license": "MIT",
29-
"homepage": "https://comparedge.com",
29+
"homepage": "https://comparedge.com/mcp",
3030
"repository": {
3131
"type": "git",
3232
"url": "git+https://github.com/comparedge/mcp-server-comparedge.git"
@@ -43,4 +43,4 @@
4343
"glama.json",
4444
"server.json"
4545
]
46-
}
46+
}

0 commit comments

Comments
 (0)