freshcontext-mcp 0.3.16 → 0.3.18

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.
Files changed (60) hide show
  1. package/.env.example +3 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +17 -0
  4. package/README.md +395 -296
  5. package/SECURITY.md +34 -0
  6. package/TRADEMARKS.md +9 -0
  7. package/dist/adapters/arxiv.js +92 -48
  8. package/dist/adapters/finance.js +87 -101
  9. package/dist/adapters/gdelt.js +1 -1
  10. package/dist/adapters/gebiz.js +1 -1
  11. package/dist/adapters/hackernews.js +59 -29
  12. package/dist/adapters/productHunt.js +8 -4
  13. package/dist/adapters/registry.js +232 -0
  14. package/dist/adapters/repoSearch.js +1 -1
  15. package/dist/adapters/secFilings.js +1 -1
  16. package/dist/core/decay.js +61 -0
  17. package/dist/core/decision.js +176 -0
  18. package/dist/core/envelope.js +59 -0
  19. package/dist/core/explain.js +28 -0
  20. package/dist/core/guards.js +17 -0
  21. package/dist/core/index.js +11 -0
  22. package/dist/core/pipeline.js +101 -0
  23. package/dist/core/provenance.js +73 -0
  24. package/dist/core/rank.js +84 -0
  25. package/dist/core/signal.js +101 -0
  26. package/dist/core/sourceProfiles.js +126 -0
  27. package/dist/core/types.js +1 -0
  28. package/dist/core/utility.js +90 -0
  29. package/dist/rest/handler.js +126 -0
  30. package/dist/security.js +1 -1
  31. package/dist/server.js +10 -10
  32. package/dist/tools/freshnessStamp.js +1 -117
  33. package/dist/types.js +0 -1
  34. package/docs/API_DESIGN.md +434 -0
  35. package/docs/CODEX_MCP_USAGE.md +116 -0
  36. package/docs/CORE_API.md +224 -0
  37. package/docs/DEPENDENCY_DILIGENCE.md +63 -0
  38. package/docs/HA_PRI_V2_DESIGN.md +279 -0
  39. package/docs/OPERATIONAL_DEMO_RUNBOOK.md +458 -0
  40. package/docs/RELEASE_INTEGRITY.md +53 -0
  41. package/docs/RELEASE_NOTES.md +38 -0
  42. package/docs/SIGNAL_CONTRACT.md +89 -0
  43. package/docs/SOURCE_PROFILES.md +427 -0
  44. package/freshcontext.schema.json +103 -103
  45. package/package-script-guard.mjs +140 -0
  46. package/package.json +92 -52
  47. package/server.json +27 -28
  48. package/.github/workflows/publish.yml +0 -32
  49. package/RESEARCH.md +0 -487
  50. package/RISKS.md +0 -137
  51. package/cleanup.ps1 +0 -99
  52. package/demo/README.md +0 -70
  53. package/demo/data.json +0 -88
  54. package/demo/generate.mjs +0 -199
  55. package/demo/index.html +0 -513
  56. package/demo/logo-export.html +0 -61
  57. package/demo/logo.svg +0 -23
  58. package/dist/apify.js +0 -133
  59. package/freshcontext-validate.js +0 -196
  60. package/time-check.ps1 +0 -46
package/SECURITY.md ADDED
@@ -0,0 +1,34 @@
1
+ # Security Policy
2
+
3
+ ## Supported Versions
4
+
5
+ FreshContext currently supports the active `freshcontext-mcp@0.3.x` package line.
6
+
7
+ Please use the latest published `0.3.x` release when reporting a vulnerability, and include the exact package version, repository, transport, and environment involved.
8
+
9
+ ## Reporting A Vulnerability
10
+
11
+ FreshContext accepts responsible security reports by email:
12
+
13
+ - gimmanuel73@gmail.com
14
+
15
+ Please do not post secrets, tokens, private logs, customer data, exploit payloads, or sensitive operational details in public GitHub issues.
16
+
17
+ For a useful report, include:
18
+
19
+ - affected repository or package
20
+ - affected version or commit
21
+ - reproduction steps
22
+ - expected and observed behavior
23
+ - security impact
24
+ - whether the issue affects local MCP usage, hosted Worker usage, examples, docs, packaging, or another surface
25
+
26
+ Public GitHub issues are fine for non-sensitive bugs, documentation mistakes, stale claims, build failures, and feature requests.
27
+
28
+ ## Scope Notes
29
+
30
+ FreshContext does not currently offer a formal bug bounty program.
31
+
32
+ Please do not send live production tokens, private Cloudflare logs, npm tokens, GitHub tokens, MCP registry tokens, customer data, or private account data. If a report requires sensitive evidence, describe the issue first by email so a safer exchange path can be agreed.
33
+
34
+ This policy does not make claims of certification, compliance, guaranteed response time, or security warranty.
package/TRADEMARKS.md ADDED
@@ -0,0 +1,9 @@
1
+ # FreshContext Brand and Trademark Notice
2
+
3
+ FreshContext is the product and project name used by the project owner for this software, documentation, examples, and related public materials.
4
+
5
+ No mark registration claim is made in this repository. Any future trademark filing, transfer, assignment, or licensing question should be reviewed separately.
6
+
7
+ Third-party names, marks, services, and platforms, including Cloudflare, npm, GitHub, Model Context Protocol, MCP, Apify, Claude, OpenAI, Anthropic, and other referenced ecosystems, belong to their respective owners.
8
+
9
+ Use of third-party names in this repository is descriptive, interoperability-oriented, or reference-oriented. It does not imply endorsement, certification, sponsorship, partnership, or official affiliation unless explicitly stated by the relevant third party.
@@ -1,66 +1,110 @@
1
- /**
2
- * arXiv adapter — uses the official arXiv API (no scraping, no auth needed).
3
- * Accepts a search query or a direct arXiv API URL.
4
- * Docs: https://arxiv.org/help/api/user-manual
5
- */
6
- export async function arxivAdapter(options) {
7
- const input = options.url.trim();
8
- // Build API URL — if they pass a plain query, construct it
9
- const apiUrl = input.startsWith("http")
10
- ? input
11
- : `https://export.arxiv.org/api/query?search_query=all:${encodeURIComponent(input)}&start=0&max_results=10&sortBy=relevance&sortOrder=descending`;
1
+ const USER_AGENT = "freshcontext-mcp/0.1.7 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)";
2
+ const DEFAULT_ARXIV_SIGNAL_SCORE = 0.8;
3
+ function buildArxivApiUrl(input, maxResults = 10) {
4
+ const trimmed = input.trim();
5
+ const safeMaxResults = Number.isFinite(maxResults)
6
+ ? Math.max(1, Math.min(Math.trunc(maxResults), 50))
7
+ : 10;
8
+ return trimmed.startsWith("http")
9
+ ? trimmed
10
+ : `https://export.arxiv.org/api/query?search_query=all:${encodeURIComponent(trimmed)}&start=0&max_results=${safeMaxResults}&sortBy=relevance&sortOrder=descending`;
11
+ }
12
+ async function fetchArxivXml(apiUrl) {
12
13
  const res = await fetch(apiUrl, {
13
- headers: { "User-Agent": "freshcontext-mcp/0.1.7 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)" },
14
+ headers: { "User-Agent": USER_AGENT },
14
15
  });
15
16
  if (!res.ok)
16
17
  throw new Error(`arXiv API error: ${res.status} ${res.statusText}`);
17
- const xml = await res.text();
18
- // Parse the Atom XML response
19
- const entries = [...xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)];
20
- if (!entries.length) {
21
- return { raw: "No results found for this query.", content_date: null, freshness_confidence: "low" };
22
- }
23
- const getTag = (block, tag) => {
24
- const m = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"));
25
- return m ? m[1].trim().replace(/\s+/g, " ") : "";
26
- };
27
- const getAttr = (block, tag, attr) => {
28
- const m = block.match(new RegExp(`<${tag}[^>]*${attr}="([^"]*)"`, "i"));
29
- return m ? m[1].trim() : "";
30
- };
31
- const papers = entries.map((match, i) => {
18
+ return res.text();
19
+ }
20
+ function getTag(block, tag) {
21
+ const m = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i"));
22
+ return m ? m[1].trim().replace(/\s+/g, " ") : "";
23
+ }
24
+ function getAttr(block, tag, attr) {
25
+ const m = block.match(new RegExp(`<${tag}[^>]*${attr}="([^"]*)"`, "i"));
26
+ return m ? m[1].trim() : "";
27
+ }
28
+ function normalizeArxivUrl(id) {
29
+ return id.replace("http://arxiv.org/abs/", "https://arxiv.org/abs/");
30
+ }
31
+ function parseArxivEntries(xml) {
32
+ return [...xml.matchAll(/<entry>([\s\S]*?)<\/entry>/g)].map((match) => {
32
33
  const block = match[1];
33
- const title = getTag(block, "title").replace(/\n/g, " ");
34
- const summary = getTag(block, "summary").slice(0, 300).replace(/\n/g, " ");
35
- const published = getTag(block, "published").slice(0, 10); // YYYY-MM-DD
36
- const updated = getTag(block, "updated").slice(0, 10);
37
- const id = getTag(block, "id").replace("http://arxiv.org/abs/", "https://arxiv.org/abs/");
38
- // Authors — can be multiple
39
34
  const authorMatches = [...block.matchAll(/<author>([\s\S]*?)<\/author>/g)];
40
35
  const authors = authorMatches
41
36
  .map(a => getTag(a[1], "name"))
42
37
  .filter(Boolean)
43
- .slice(0, 4)
44
- .join(", ");
45
- // Categories
46
- const primaryCat = getAttr(block, "arxiv:primary_category", "term") ||
47
- getAttr(block, "category", "term");
48
- return [
49
- `[${i + 1}] ${title}`,
50
- `Authors: ${authors || "Unknown"}`,
51
- `Published: ${published}${updated !== published ? ` (updated ${updated})` : ""}`,
52
- primaryCat ? `Category: ${primaryCat}` : null,
53
- `Abstract: ${summary}…`,
54
- `Link: ${id}`,
55
- ].filter(Boolean).join("\n");
38
+ .slice(0, 4);
39
+ return {
40
+ title: getTag(block, "title").replace(/\n/g, " "),
41
+ summary: getTag(block, "summary").replace(/\n/g, " "),
42
+ published: getTag(block, "published"),
43
+ updated: getTag(block, "updated"),
44
+ id: normalizeArxivUrl(getTag(block, "id")),
45
+ authors,
46
+ category: getAttr(block, "arxiv:primary_category", "term") ||
47
+ getAttr(block, "category", "term"),
48
+ };
56
49
  });
50
+ }
51
+ function formatArxivEntry(entry, index) {
52
+ const published = entry.published.slice(0, 10);
53
+ const updated = entry.updated.slice(0, 10);
54
+ const authors = entry.authors.join(", ");
55
+ const summary = entry.summary.slice(0, 300);
56
+ return [
57
+ `[${index + 1}] ${entry.title}`,
58
+ `Authors: ${authors || "Unknown"}`,
59
+ `Published: ${published}${updated !== published ? ` (updated ${updated})` : ""}`,
60
+ entry.category ? `Category: ${entry.category}` : null,
61
+ `Abstract: ${summary}\u00e2\u20ac\u00a6`,
62
+ `Link: ${entry.id}`,
63
+ ].filter(Boolean).join("\n");
64
+ }
65
+ /**
66
+ * arXiv adapter uses the official arXiv API.
67
+ * Accepts a search query or a direct arXiv API URL.
68
+ * Docs: https://arxiv.org/help/api/user-manual
69
+ */
70
+ export async function arxivAdapter(options) {
71
+ const input = options.url.trim();
72
+ const apiUrl = buildArxivApiUrl(input);
73
+ const xml = await fetchArxivXml(apiUrl);
74
+ const entries = parseArxivEntries(xml);
75
+ if (!entries.length) {
76
+ return { raw: "No results found for this query.", content_date: null, freshness_confidence: "low" };
77
+ }
78
+ const papers = entries.map(formatArxivEntry);
57
79
  const raw = papers.join("\n\n").slice(0, options.maxLength ?? 6000);
58
- // Most recent publication date
59
80
  const dates = entries
60
- .map(m => getTag(m[1], "published").slice(0, 10))
81
+ .map(entry => entry.published.slice(0, 10))
61
82
  .filter(Boolean)
62
83
  .sort()
63
84
  .reverse();
64
85
  const content_date = dates[0] ?? null;
65
86
  return { raw, content_date, freshness_confidence: content_date ? "high" : "medium" };
66
87
  }
88
+ export async function searchArxivSignals(input) {
89
+ const query = input.query.trim();
90
+ const apiUrl = buildArxivApiUrl(query, input.maxResults);
91
+ const xml = await fetchArxivXml(apiUrl);
92
+ const entries = parseArxivEntries(xml);
93
+ const retrievedAt = input.retrievedAt ?? new Date().toISOString();
94
+ const semanticScore = input.semanticScore ?? DEFAULT_ARXIV_SIGNAL_SCORE;
95
+ return entries.map((entry) => ({
96
+ title: entry.title,
97
+ content: entry.summary,
98
+ source: entry.id,
99
+ source_type: "arxiv",
100
+ published_at: entry.published || null,
101
+ retrieved_at: retrievedAt,
102
+ semantic_score: semanticScore,
103
+ metadata: {
104
+ authors: entry.authors,
105
+ category: entry.category || null,
106
+ updated_at: entry.updated || null,
107
+ query,
108
+ },
109
+ }));
110
+ }
@@ -1,117 +1,103 @@
1
- function formatMarketCap(cap) {
2
- if (!cap)
3
- return "N/A";
4
- if (cap >= 1e12)
5
- return `$${(cap / 1e12).toFixed(2)}T`;
6
- if (cap >= 1e9)
7
- return `$${(cap / 1e9).toFixed(2)}B`;
8
- if (cap >= 1e6)
9
- return `$${(cap / 1e6).toFixed(2)}M`;
10
- return `$${cap.toLocaleString()}`;
1
+ function toStooqSymbol(ticker) {
2
+ const clean = ticker.trim().toUpperCase().replace(/[^A-Z0-9.^=-]/g, "");
3
+ if (!clean)
4
+ throw new Error("Ticker cannot be empty");
5
+ if (clean.includes(".") || clean.startsWith("^") || clean.includes("="))
6
+ return clean;
7
+ return `${clean}.US`;
11
8
  }
12
- function formatChange(change, pct) {
13
- if (change === undefined || pct === undefined)
14
- return "N/A";
15
- const sign = change >= 0 ? "+" : "";
16
- return `${sign}${change.toFixed(2)} (${sign}${pct.toFixed(2)}%)`;
9
+ function toNumber(value) {
10
+ if (value === undefined || value === "N/D")
11
+ return null;
12
+ const n = typeof value === "number" ? value : Number(value);
13
+ return Number.isFinite(n) ? n : null;
14
+ }
15
+ function normalizeQuoteTimestamp(date, time) {
16
+ if (!date || date === "N/D")
17
+ throw new Error("Quote date unavailable");
18
+ const clock = time && time !== "N/D" ? time : "00:00:00";
19
+ const parsed = new Date(`${date}T${clock}Z`);
20
+ if (Number.isNaN(parsed.getTime()))
21
+ throw new Error(`Invalid quote timestamp: ${date} ${time}`);
22
+ return parsed.toISOString();
23
+ }
24
+ function formatNumber(value, prefix = "") {
25
+ const n = toNumber(value);
26
+ return n === null ? "N/A" : `${prefix}${n.toLocaleString(undefined, { maximumFractionDigits: 4 })}`;
27
+ }
28
+ async function fetchStooqQuote(ticker) {
29
+ const stooqSymbol = toStooqSymbol(ticker);
30
+ const url = `https://stooq.com/q/l/?s=${encodeURIComponent(stooqSymbol.toLowerCase())}&f=sd2t2ohlcv&h&e=json`;
31
+ const res = await fetch(url, {
32
+ headers: {
33
+ "User-Agent": "freshcontext-mcp/0.3.17",
34
+ "Accept": "application/json",
35
+ },
36
+ });
37
+ if (!res.ok)
38
+ throw new Error(`Stooq quote API error: ${res.status}`);
39
+ const data = await res.json();
40
+ const quote = data.symbols?.[0];
41
+ if (!quote)
42
+ throw new Error(`No quote returned for ${ticker}`);
43
+ if (quote.close === "N/D" || quote.date === "N/D") {
44
+ throw new Error(`No Stooq quote data found for ${ticker}`);
45
+ }
46
+ return {
47
+ requested: ticker,
48
+ stooqSymbol,
49
+ quote,
50
+ timestamp: normalizeQuoteTimestamp(quote.date, quote.time),
51
+ };
52
+ }
53
+ function formatQuote(result) {
54
+ const q = result.quote;
55
+ return [
56
+ `${result.requested.toUpperCase()} — ${q.symbol}`,
57
+ `source: stooq`,
58
+ `Quote timestamp: ${result.timestamp}`,
59
+ "",
60
+ `Close: ${formatNumber(q.close, "$")}`,
61
+ `Open: ${formatNumber(q.open, "$")}`,
62
+ `High: ${formatNumber(q.high, "$")}`,
63
+ `Low: ${formatNumber(q.low, "$")}`,
64
+ `Volume: ${formatNumber(q.volume)}`,
65
+ ].join("\n");
17
66
  }
18
67
  export async function financeAdapter(options) {
19
68
  const input = options.url.trim();
20
- // Support comma-separated tickers
21
69
  const rawTickers = input
22
70
  .split(",")
23
- .map((t) => t.trim().toUpperCase())
71
+ .map((t) => t.trim())
24
72
  .filter(Boolean)
25
- .slice(0, 5); // max 5 at once
26
- const results = [];
27
- let latestTimestamp = null;
73
+ .slice(0, 5);
74
+ if (!rawTickers.length)
75
+ throw new Error("At least one ticker is required");
76
+ const successes = [];
77
+ const failures = [];
28
78
  for (const ticker of rawTickers) {
29
79
  try {
30
- const quoteData = await fetchQuote(ticker);
31
- if (quoteData) {
32
- results.push(formatQuote(quoteData));
33
- if (quoteData.regularMarketTime) {
34
- latestTimestamp = Math.max(latestTimestamp ?? 0, quoteData.regularMarketTime);
35
- }
36
- }
80
+ successes.push(await fetchStooqQuote(ticker));
37
81
  }
38
82
  catch (err) {
39
- results.push(`[${ticker}] Error: ${err instanceof Error ? err.message : String(err)}`);
83
+ failures.push(`[${ticker.toUpperCase()}] ${err instanceof Error ? err.message : String(err)}`);
40
84
  }
41
85
  }
42
- const raw = results.join("\n\n─────────────────────────────\n\n").slice(0, options.maxLength ?? 5000);
43
- const content_date = latestTimestamp
44
- ? new Date(latestTimestamp * 1000).toISOString()
45
- : new Date().toISOString();
46
- return { raw, content_date, freshness_confidence: "high" };
47
- }
48
- async function fetchQuote(ticker) {
49
- // v7 quote endpoint — public, no auth
50
- const quoteUrl = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(ticker)}&fields=shortName,longName,regularMarketPrice,regularMarketChange,regularMarketChangePercent,marketCap,regularMarketVolume,fiftyTwoWeekHigh,fiftyTwoWeekLow,trailingPE,dividendYield,currency,exchangeName,regularMarketTime`;
51
- const quoteRes = await fetch(quoteUrl, {
52
- headers: {
53
- "User-Agent": "Mozilla/5.0 (compatible; freshcontext-mcp/0.1.5)",
54
- "Accept": "application/json",
55
- },
56
- });
57
- if (!quoteRes.ok)
58
- throw new Error(`Yahoo Finance API error: ${quoteRes.status}`);
59
- const quoteJson = await quoteRes.json();
60
- const quote = quoteJson?.quoteResponse?.result?.[0];
61
- if (!quote)
62
- throw new Error(`No data found for ticker: ${ticker}`);
63
- // Optionally fetch company summary (v11 quoteSummary)
64
- try {
65
- const summaryUrl = `https://query1.finance.yahoo.com/v11/finance/quoteSummary/${encodeURIComponent(ticker)}?modules=assetProfile`;
66
- const summaryRes = await fetch(summaryUrl, {
67
- headers: { "User-Agent": "Mozilla/5.0 (compatible; freshcontext-mcp/0.1.5)" },
68
- });
69
- if (summaryRes.ok) {
70
- const summaryJson = await summaryRes.json();
71
- const profile = summaryJson?.quoteSummary?.result?.[0]?.assetProfile;
72
- if (profile) {
73
- Object.assign(quote, {
74
- longBusinessSummary: profile.longBusinessSummary,
75
- sector: profile.sector,
76
- industry: profile.industry,
77
- fullTimeEmployees: profile.fullTimeEmployees,
78
- website: profile.website,
79
- });
80
- }
81
- }
82
- }
83
- catch {
84
- // Summary is optional — continue without it
85
- }
86
- return quote;
87
- }
88
- function formatQuote(q) {
89
- const lines = [
90
- `${q.symbol} — ${q.longName ?? q.shortName ?? "Unknown"}`,
91
- `Exchange: ${q.exchangeName ?? "N/A"} · Currency: ${q.currency ?? "USD"}`,
92
- "",
93
- `Price: ${q.regularMarketPrice !== undefined ? `$${q.regularMarketPrice.toFixed(2)}` : "N/A"}`,
94
- `Change: ${formatChange(q.regularMarketChange, q.regularMarketChangePercent)}`,
95
- `Market Cap: ${formatMarketCap(q.marketCap)}`,
96
- `Volume: ${q.regularMarketVolume?.toLocaleString() ?? "N/A"}`,
97
- `52w High: ${q.fiftyTwoWeekHigh !== undefined ? `$${q.fiftyTwoWeekHigh.toFixed(2)}` : "N/A"}`,
98
- `52w Low: ${q.fiftyTwoWeekLow !== undefined ? `$${q.fiftyTwoWeekLow.toFixed(2)}` : "N/A"}`,
99
- `P/E Ratio: ${q.trailingPE !== undefined ? q.trailingPE.toFixed(2) : "N/A"}`,
100
- `Div Yield: ${q.dividendYield !== undefined ? `${(q.dividendYield * 100).toFixed(2)}%` : "N/A"}`,
101
- ];
102
- if (q.sector || q.industry) {
103
- lines.push("");
104
- if (q.sector)
105
- lines.push(`Sector: ${q.sector}`);
106
- if (q.industry)
107
- lines.push(`Industry: ${q.industry}`);
108
- if (q.fullTimeEmployees)
109
- lines.push(`Employees: ${q.fullTimeEmployees.toLocaleString()}`);
110
- if (q.website)
111
- lines.push(`Website: ${q.website}`);
86
+ if (!successes.length) {
87
+ throw new Error(`Finance quote lookup failed for all tickers via source=stooq. ${failures.join("; ")}`);
112
88
  }
113
- if (q.longBusinessSummary) {
114
- lines.push("", "About:", q.longBusinessSummary.slice(0, 500) + (q.longBusinessSummary.length > 500 ? "…" : ""));
89
+ const sections = successes.map(formatQuote);
90
+ if (failures.length) {
91
+ sections.push(["Partial failures:", ...failures.map((f) => `- ${f}`)].join("\n"));
115
92
  }
116
- return lines.join("\n");
93
+ const raw = sections.join("\n\n-----------------------------\n\n").slice(0, options.maxLength ?? 5000);
94
+ const content_date = successes
95
+ .map((s) => s.timestamp)
96
+ .sort()
97
+ .reverse()[0] ?? null;
98
+ return {
99
+ raw,
100
+ content_date,
101
+ freshness_confidence: failures.length ? "medium" : "high",
102
+ };
117
103
  }
@@ -11,7 +11,7 @@
11
11
  */
12
12
  const HEADERS = {
13
13
  "Accept": "application/json",
14
- "User-Agent": "freshcontext-mcp/1.0 contact@freshcontext.dev",
14
+ "User-Agent": "freshcontext-mcp/0.3.17 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
15
15
  };
16
16
  function parseGdeltDate(raw) {
17
17
  if (!raw)
@@ -20,7 +20,7 @@ const DATASET_ID = "d_acde1106003906a75c3fa052592f2fcb";
20
20
  const BASE_URL = "https://data.gov.sg/api/action/datastore_search";
21
21
  const HEADERS = {
22
22
  "Accept": "application/json",
23
- "User-Agent": "freshcontext-mcp/1.0 contact@freshcontext.dev",
23
+ "User-Agent": "freshcontext-mcp/0.3.17 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
24
24
  };
25
25
  function formatDate(raw) {
26
26
  if (!raw)
@@ -1,9 +1,29 @@
1
1
  import { chromium } from "playwright";
2
2
  import { validateUrl } from "../security.js";
3
+ function isUrl(input) {
4
+ try {
5
+ new URL(input);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ function normalizeHnDate(raw) {
13
+ if (!raw)
14
+ return null;
15
+ const match = raw.match(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z?\b/);
16
+ if (!match)
17
+ return null;
18
+ const isoLike = match[0].endsWith("Z") ? match[0] : `${match[0]}Z`;
19
+ const parsed = new Date(isoLike);
20
+ return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
21
+ }
3
22
  export async function hackerNewsAdapter(options) {
4
- // Validate URL — allow both HN and Algolia domains
5
- validateUrl(options.url, "hackernews");
6
- const url = options.url;
23
+ const input = options.url.trim();
24
+ if (!input)
25
+ throw new Error("HN URL or search query is required");
26
+ const url = isUrl(input) ? validateUrl(input, "hackernews") : `hn-search:${input}`;
7
27
  if (url.includes("hn.algolia.com/api/") || url.startsWith("hn-search:")) {
8
28
  const query = url.startsWith("hn-search:")
9
29
  ? url.replace("hn-search:", "").trim()
@@ -20,45 +40,55 @@ export async function hackerNewsAdapter(options) {
20
40
  `[${i + 1}] ${r.title ?? "Untitled"}`,
21
41
  `URL: ${r.url ?? `https://news.ycombinator.com/item?id=${r.objectID}`}`,
22
42
  `Score: ${r.points} points | ${r.num_comments} comments`,
23
- `Author: ${r.author} | Posted: ${r.created_at}`,
43
+ `Author: ${r.author} | Posted: ${normalizeHnDate(r.created_at) ?? r.created_at}`,
24
44
  ].join("\n"))
25
45
  .join("\n\n")
26
46
  .slice(0, options.maxLength ?? 4000);
27
- const newest = data.hits.map((r) => r.created_at).sort().reverse()[0] ?? null;
47
+ const newest = data.hits
48
+ .map((r) => normalizeHnDate(r.created_at))
49
+ .filter((d) => Boolean(d))
50
+ .sort()
51
+ .reverse()[0] ?? null;
28
52
  return { raw, content_date: newest, freshness_confidence: newest ? "high" : "medium" };
29
53
  }
30
- // Default: browser-based scrape for HN front page or search pages
31
54
  const browser = await chromium.launch({ headless: true });
32
55
  const page = await browser.newPage();
33
56
  await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20000 });
34
- const data = await page.evaluate(`(function() {
35
- var items = Array.from(document.querySelectorAll('.athing')).slice(0, 20);
36
- var results = items.map(function(el) {
37
- var titleLineEl = el.querySelector('.titleline > a');
38
- var title = titleLineEl ? titleLineEl.textContent.trim() : null;
39
- var link = titleLineEl ? titleLineEl.getAttribute('href') : null;
40
- var subtext = el.nextElementSibling;
41
- var scoreEl = subtext ? subtext.querySelector('.score') : null;
42
- var score = scoreEl ? scoreEl.textContent.trim() : null;
43
- var ageEl = subtext ? subtext.querySelector('.age') : null;
44
- var age = ageEl ? ageEl.getAttribute('title') : null;
45
- var anchors = subtext ? subtext.querySelectorAll('a') : [];
46
- var commentLink = anchors.length > 0 ? anchors[anchors.length - 1].textContent.trim() : null;
47
- return { title: title, link: link, score: score, age: age, commentLink: commentLink };
48
- });
49
- return results;
57
+ const data = await page.evaluate(`(function() {
58
+ var items = Array.from(document.querySelectorAll('.athing')).slice(0, 20);
59
+ var results = items.map(function(el) {
60
+ var titleLineEl = el.querySelector('.titleline > a');
61
+ var title = titleLineEl ? titleLineEl.textContent.trim() : null;
62
+ var link = titleLineEl ? titleLineEl.getAttribute('href') : null;
63
+ var subtext = el.nextElementSibling;
64
+ var scoreEl = subtext ? subtext.querySelector('.score') : null;
65
+ var score = scoreEl ? scoreEl.textContent.trim() : null;
66
+ var ageEl = subtext ? subtext.querySelector('.age') : null;
67
+ var age = ageEl ? ageEl.getAttribute('title') : null;
68
+ var anchors = subtext ? subtext.querySelectorAll('a') : [];
69
+ var commentLink = anchors.length > 0 ? anchors[anchors.length - 1].textContent.trim() : null;
70
+ return { title: title, link: link, score: score, age: age, commentLink: commentLink };
71
+ });
72
+ return results;
50
73
  })()`);
51
74
  await browser.close();
52
75
  const typedData = data;
53
76
  const raw = typedData
54
- .map((r, i) => [
55
- `[${i + 1}] ${r.title ?? "Untitled"}`,
56
- `URL: ${r.link ?? "N/A"}`,
57
- `Score: ${r.score ?? "N/A"} | ${r.commentLink ?? ""}`,
58
- `Posted: ${r.age ?? "unknown"}`,
59
- ].join("\n"))
77
+ .map((r, i) => {
78
+ const date = normalizeHnDate(r.age);
79
+ return [
80
+ `[${i + 1}] ${r.title ?? "Untitled"}`,
81
+ `URL: ${r.link ?? "N/A"}`,
82
+ `Score: ${r.score ?? "N/A"} | ${r.commentLink ?? ""}`,
83
+ `Posted: ${date ?? "unknown"}`,
84
+ ].join("\n");
85
+ })
60
86
  .join("\n\n");
61
- const newestDate = typedData.map((r) => r.age).filter(Boolean).sort().reverse()[0] ?? null;
87
+ const newestDate = typedData
88
+ .map((r) => normalizeHnDate(r.age))
89
+ .filter((d) => Boolean(d))
90
+ .sort()
91
+ .reverse()[0] ?? null;
62
92
  return {
63
93
  raw,
64
94
  content_date: newestDate,
@@ -1,5 +1,10 @@
1
+ import { validateUrl } from "../security.js";
1
2
  export async function productHuntAdapter(options) {
2
- // PH GraphQL API public, no auth for published posts
3
+ const token = process.env.PH_TOKEN?.trim() || process.env.PRODUCTHUNT_TOKEN?.trim();
4
+ if (!token)
5
+ return scrapeProductHunt(options);
6
+ // Product Hunt GraphQL API requires a bearer token. Keep it in env/secrets,
7
+ // never in source.
3
8
  const query = options.url.startsWith("http")
4
9
  ? null
5
10
  : options.url;
@@ -28,8 +33,7 @@ export async function productHuntAdapter(options) {
28
33
  method: "POST",
29
34
  headers: {
30
35
  "Content-Type": "application/json",
31
- // Public access token (read-only, rate-limited but usable)
32
- "Authorization": "Bearer irgTzMNAz-S-p1P8H5pFCxzU4TEF7GIJZ8vZZi0gLJg",
36
+ "Authorization": `Bearer ${token}`,
33
37
  },
34
38
  body: JSON.stringify({ query: gql }),
35
39
  });
@@ -68,7 +72,7 @@ export async function productHuntAdapter(options) {
68
72
  async function scrapeProductHunt(options) {
69
73
  const { chromium } = await import("playwright");
70
74
  const url = options.url.startsWith("http")
71
- ? options.url
75
+ ? validateUrl(options.url, "productHunt")
72
76
  : `https://www.producthunt.com/search?q=${encodeURIComponent(options.url)}`;
73
77
  const browser = await chromium.launch({ headless: true });
74
78
  const page = await browser.newPage();