freshcontext-mcp 0.3.14 → 0.3.16

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/server.js CHANGED
@@ -11,6 +11,7 @@ import { packageTrendsAdapter } from "./adapters/packageTrends.js";
11
11
  import { redditAdapter } from "./adapters/reddit.js";
12
12
  import { productHuntAdapter } from "./adapters/productHunt.js";
13
13
  import { financeAdapter } from "./adapters/finance.js";
14
+ import { arxivAdapter } from "./adapters/arxiv.js";
14
15
  import { jobsAdapter } from "./adapters/jobs.js";
15
16
  import { changelogAdapter } from "./adapters/changelog.js";
16
17
  import { govContractsAdapter } from "./adapters/govcontracts.js";
@@ -88,7 +89,7 @@ server.registerTool("extract_yc", {
88
89
  }, async ({ url, max_length }) => {
89
90
  try {
90
91
  const result = await ycAdapter({ url, maxLength: max_length });
91
- const ctx = stampFreshness(result, { url, maxLength: max_length }, "ycombinator");
92
+ const ctx = stampFreshness(result, { url, maxLength: max_length }, "yc");
92
93
  return { content: [{ type: "text", text: formatForLLM(ctx) }] };
93
94
  }
94
95
  catch (err) {
@@ -106,7 +107,7 @@ server.registerTool("search_repos", {
106
107
  }, async ({ query, max_length }) => {
107
108
  try {
108
109
  const result = await repoSearchAdapter({ url: query, maxLength: max_length });
109
- const ctx = stampFreshness(result, { url: query, maxLength: max_length }, "github_search");
110
+ const ctx = stampFreshness(result, { url: query, maxLength: max_length }, "reposearch");
110
111
  return { content: [{ type: "text", text: formatForLLM(ctx) }] };
111
112
  }
112
113
  catch (err) {
@@ -124,13 +125,97 @@ server.registerTool("package_trends", {
124
125
  }, async ({ packages, max_length }) => {
125
126
  try {
126
127
  const result = await packageTrendsAdapter({ url: packages, maxLength: max_length });
127
- const ctx = stampFreshness(result, { url: packages, maxLength: max_length }, "package_registry");
128
+ const ctx = stampFreshness(result, { url: packages, maxLength: max_length }, "packagetrends");
128
129
  return { content: [{ type: "text", text: formatForLLM(ctx) }] };
129
130
  }
130
131
  catch (err) {
131
132
  return { content: [{ type: "text", text: formatSecurityError(err) }] };
132
133
  }
133
134
  });
135
+ // ─── Tool: extract_arxiv ─────────────────────────────────────────────────────
136
+ server.registerTool("extract_arxiv", {
137
+ description: "Search arXiv for research papers via the official API. Pass a topic, keyword, or full arXiv API URL. Returns titles, authors, publication dates, primary category, and abstracts — all timestamped.",
138
+ inputSchema: z.object({
139
+ url: z.string().describe("Search query e.g. 'temporal retrieval', or a full arXiv API URL"),
140
+ max_length: z.number().optional().default(6000),
141
+ }),
142
+ annotations: { readOnlyHint: true, openWorldHint: true },
143
+ }, async ({ url, max_length }) => {
144
+ try {
145
+ const result = await arxivAdapter({ url, maxLength: max_length });
146
+ const ctx = stampFreshness(result, { url, maxLength: max_length }, "arxiv");
147
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
148
+ }
149
+ catch (err) {
150
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
151
+ }
152
+ });
153
+ // ─── Tool: extract_finance ───────────────────────────────────────────────────
154
+ server.registerTool("extract_finance", {
155
+ description: "Live stock data via Yahoo Finance — price, change, market cap, P/E, 52w range, sector, business summary. Accepts up to 5 comma-separated tickers. Returns timestamped freshcontext.",
156
+ inputSchema: z.object({
157
+ url: z.string().describe("Ticker symbol(s) e.g. 'AAPL' or 'MSFT,GOOG,PLTR'"),
158
+ max_length: z.number().optional().default(5000),
159
+ }),
160
+ annotations: { readOnlyHint: true, openWorldHint: true },
161
+ }, async ({ url, max_length }) => {
162
+ try {
163
+ const result = await financeAdapter({ url, maxLength: max_length });
164
+ const ctx = stampFreshness(result, { url, maxLength: max_length }, "finance");
165
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
166
+ }
167
+ catch (err) {
168
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
169
+ }
170
+ });
171
+ // ─── Tool: extract_reddit ────────────────────────────────────────────────────
172
+ server.registerTool("extract_reddit", {
173
+ description: "Extract posts and community sentiment from Reddit via the public JSON API. Accepts a subreddit URL (https://www.reddit.com/r/MachineLearning/.json), a search URL, or a subreddit shorthand ('r/MachineLearning'). Returns titles, authors, scores, comment counts, and per-post timestamps.",
174
+ inputSchema: z.object({
175
+ url: z.string().describe("Subreddit URL, search URL, or 'r/<subreddit>' shorthand"),
176
+ max_length: z.number().optional().default(6000),
177
+ }),
178
+ annotations: { readOnlyHint: true, openWorldHint: true },
179
+ }, async ({ url, max_length }) => {
180
+ try {
181
+ const result = await redditAdapter({ url, maxLength: max_length });
182
+ const ctx = stampFreshness(result, { url, maxLength: max_length }, "reddit");
183
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
184
+ }
185
+ catch (err) {
186
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
187
+ }
188
+ });
189
+ // ─── Tool: extract_producthunt ───────────────────────────────────────────────
190
+ server.registerTool("extract_producthunt", {
191
+ description: "Recent Product Hunt launches by keyword or topic. Uses the Product Hunt GraphQL API (with HTML scrape fallback). Returns names, taglines, vote counts, comment counts, topics, and launch dates — all timestamped.",
192
+ inputSchema: z.object({
193
+ url: z.string().describe("Search query e.g. 'mcp ai agents' or a Product Hunt topic URL"),
194
+ max_length: z.number().optional().default(6000),
195
+ }),
196
+ annotations: { readOnlyHint: true, openWorldHint: true },
197
+ }, async ({ url, max_length }) => {
198
+ try {
199
+ const result = await productHuntAdapter({ url, maxLength: max_length });
200
+ const ctx = stampFreshness(result, { url, maxLength: max_length }, "producthunt");
201
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
202
+ }
203
+ catch (err) {
204
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
205
+ }
206
+ });
207
+ function sectionWithFreshnessCheck(label, result, adapterName, minScore, errorWord = "Unavailable") {
208
+ if (result.status !== "fulfilled") {
209
+ return `## ${label}\n[${errorWord}: ${result.reason}]`;
210
+ }
211
+ if (minScore !== undefined && minScore > 0) {
212
+ const ctx = stampFreshness(result.value, { url: "", maxLength: 0 }, adapterName);
213
+ if (ctx.freshness_score !== null && ctx.freshness_score < minScore) {
214
+ return `## ${label}\n[Stale — freshness_score: ${ctx.freshness_score}/100 is below min_freshness_score threshold of ${minScore}. Content date: ${result.value.content_date ?? "unknown"}. Re-query for fresher data.]`;
215
+ }
216
+ }
217
+ return `## ${label}\n${result.value.raw}`;
218
+ }
134
219
  // ─── Tool: extract_landscape ─────────────────────────────────────────────────
135
220
  server.registerTool("extract_landscape", {
136
221
  description: "Composite intelligence tool. Given a project idea or keyword, simultaneously queries YC startups, GitHub repos, HN, Reddit, Product Hunt, and package registries to answer: Who is building this? Is it funded? What's getting traction? Returns a unified 6-source timestamped landscape report.",
@@ -237,37 +322,31 @@ server.registerTool("extract_gov_landscape", {
237
322
  query: z.string().describe("Company name (e.g. 'Palantir'), keyword (e.g. 'artificial intelligence'), or NAICS code (e.g. '541511'). For GitHub and changelog sections, also optionally provide a GitHub URL."),
238
323
  github_url: z.string().optional().describe("Optional GitHub repo URL for the company (e.g. 'https://github.com/palantir/palantir-java-format'). If omitted, GitHub and changelog sections use the query as a search term."),
239
324
  max_length: z.number().optional().default(12000),
325
+ min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
240
326
  }),
241
327
  annotations: { readOnlyHint: true, openWorldHint: true },
242
- }, async ({ query, github_url, max_length }) => {
328
+ }, async ({ query, github_url, max_length, min_freshness_score }) => {
243
329
  const perSection = Math.floor((max_length ?? 12000) / 4);
244
- // All four sources fire in parallel — if one fails the others still return
245
330
  const [contractsResult, hnResult, repoResult, changelogResult] = await Promise.allSettled([
246
- // 1. The anchor: who is actually winning federal money in this space
247
331
  govContractsAdapter({ url: query, maxLength: perSection }),
248
- // 2. Dev community signal: does anyone in tech know about these companies
249
332
  hackerNewsAdapter({
250
333
  url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}&tags=story&hitsPerPage=10`,
251
334
  maxLength: perSection,
252
335
  }),
253
- // 3. GitHub activity: are they actually building, or contract farmers
254
336
  repoSearchAdapter({ url: github_url ?? query, maxLength: perSection }),
255
- // 4. Release velocity: how fast are they shipping product
256
337
  changelogAdapter({ url: github_url ?? query, maxLength: perSection }),
257
338
  ]);
258
- const section = (label, result) => result.status === "fulfilled"
259
- ? `## ${label}\n${result.value.raw}`
260
- : `## ${label}\n[Unavailable: ${result.reason}]`;
261
339
  const combined = [
262
340
  `# Government Intelligence Landscape: "${query}"`,
263
341
  `Generated: ${new Date().toISOString()}`,
264
342
  `Sources: USASpending.gov · Hacker News · GitHub · Changelog`,
343
+ min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
265
344
  "",
266
- section("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult),
267
- section("💬 Developer Community Awareness (Hacker News)", hnResult),
268
- section("📦 GitHub Repository Activity", repoResult),
269
- section("🔄 Product Release Velocity (Changelog)", changelogResult),
270
- ].join("\n\n");
345
+ sectionWithFreshnessCheck("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult, "govcontracts", min_freshness_score),
346
+ sectionWithFreshnessCheck("💬 Developer Community Awareness (Hacker News)", hnResult, "hackernews", min_freshness_score),
347
+ sectionWithFreshnessCheck("📦 GitHub Repository Activity", repoResult, "reposearch", min_freshness_score),
348
+ sectionWithFreshnessCheck("🔄 Product Release Velocity (Changelog)", changelogResult, "changelog", min_freshness_score),
349
+ ].filter(Boolean).join("\n\n");
271
350
  return { content: [{ type: "text", text: combined }] };
272
351
  });
273
352
  // ─── Tool: extract_finance_landscape ─────────────────────────────────────────
@@ -282,43 +361,35 @@ server.registerTool("extract_finance_landscape", {
282
361
  company_name: z.string().optional().describe("Company name for HN/Reddit/GitHub searches e.g. 'Palantir'. If omitted, derived from the ticker."),
283
362
  github_query: z.string().optional().describe("GitHub search query or repo URL for the company's tech ecosystem e.g. 'palantir' or 'https://github.com/palantir/foundry'. If omitted, uses company_name."),
284
363
  max_length: z.number().optional().default(12000),
364
+ min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
285
365
  }),
286
366
  annotations: { readOnlyHint: true, openWorldHint: true },
287
- }, async ({ tickers, company_name, github_query, max_length }) => {
367
+ }, async ({ tickers, company_name, github_query, max_length, min_freshness_score }) => {
288
368
  const perSection = Math.floor((max_length ?? 12000) / 5);
289
- // Derive a search term: prefer explicit company_name, fall back to first ticker
290
369
  const searchTerm = company_name ?? tickers.split(",")[0].trim();
291
370
  const repoQuery = github_query ?? searchTerm;
292
- // All five sources fire in parallel
293
371
  const [priceResult, hnResult, redditResult, repoResult, changelogResult] = await Promise.allSettled([
294
- // 1. The anchor: live price, market cap, P/E, 52w range
295
372
  financeAdapter({ url: tickers, maxLength: perSection }),
296
- // 2. Developer sentiment: what engineers think of this company's tech
297
373
  hackerNewsAdapter({
298
374
  url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(searchTerm)}&tags=story&hitsPerPage=10`,
299
375
  maxLength: perSection,
300
376
  }),
301
- // 3. Broader community: investor and tech community discussion on Reddit
302
377
  redditAdapter({ url: `https://www.reddit.com/search.json?q=${encodeURIComponent(searchTerm)}&sort=new&limit=15`, maxLength: perSection }),
303
- // 4. Repo ecosystem: how many GitHub projects orbit this company's technology
304
378
  repoSearchAdapter({ url: repoQuery, maxLength: perSection }),
305
- // 5. Release velocity: is the company actually shipping product right now
306
379
  changelogAdapter({ url: repoQuery, maxLength: perSection }),
307
380
  ]);
308
- const section = (label, result) => result.status === "fulfilled"
309
- ? `## ${label}\n${result.value.raw}`
310
- : `## ${label}\n[Unavailable: ${result.reason}]`;
311
381
  const combined = [
312
382
  `# Finance + Developer Intelligence: "${tickers}"${company_name ? ` (${company_name})` : ""}`,
313
383
  `Generated: ${new Date().toISOString()}`,
314
384
  `Sources: Yahoo Finance · Hacker News · Reddit · GitHub · Changelog`,
385
+ min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
315
386
  "",
316
- section("📈 Market Data (Yahoo Finance)", priceResult),
317
- section("💬 Developer Sentiment (Hacker News)", hnResult),
318
- section("🗣️ Community Discussion (Reddit)", redditResult),
319
- section("📦 Repo Ecosystem (GitHub)", repoResult),
320
- section("🔄 Product Release Velocity (Changelog)", changelogResult),
321
- ].join("\n\n");
387
+ sectionWithFreshnessCheck("📈 Market Data (Yahoo Finance)", priceResult, "finance", min_freshness_score),
388
+ sectionWithFreshnessCheck("💬 Developer Sentiment (Hacker News)", hnResult, "hackernews", min_freshness_score),
389
+ sectionWithFreshnessCheck("🗣️ Community Discussion (Reddit)", redditResult, "reddit", min_freshness_score),
390
+ sectionWithFreshnessCheck("📦 Repo Ecosystem (GitHub)", repoResult, "reposearch", min_freshness_score),
391
+ sectionWithFreshnessCheck("🔄 Product Release Velocity (Changelog)", changelogResult, "changelog", min_freshness_score),
392
+ ].filter(Boolean).join("\n\n");
322
393
  return { content: [{ type: "text", text: combined }] };
323
394
  });
324
395
  // ─── Tool: extract_sec_filings ─────────────────────────────────────────────
@@ -377,37 +448,31 @@ server.registerTool("extract_company_landscape", {
377
448
  ticker: z.string().optional().describe("Stock ticker for finance data e.g. 'PLTR'. Leave blank for private companies."),
378
449
  github_url: z.string().optional().describe("Optional GitHub repo or org URL e.g. 'https://github.com/palantir'. Improves changelog accuracy."),
379
450
  max_length: z.number().optional().default(15000),
451
+ min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
380
452
  }),
381
453
  annotations: { readOnlyHint: true, openWorldHint: true },
382
- }, async ({ company, ticker, github_url, max_length }) => {
454
+ }, async ({ company, ticker, github_url, max_length, min_freshness_score }) => {
383
455
  const perSection = Math.floor((max_length ?? 15000) / 5);
384
456
  const repoQuery = github_url ?? company;
385
457
  const [secResult, contractsResult, gdeltResult, changelogResult, financeResult] = await Promise.allSettled([
386
- // 1. What did they legally just disclose
387
458
  secFilingsAdapter({ url: company, maxLength: perSection }),
388
- // 2. Who is giving them government money
389
459
  govContractsAdapter({ url: company, maxLength: perSection }),
390
- // 3. What is global news saying right now
391
460
  gdeltAdapter({ url: company, maxLength: perSection }),
392
- // 4. Are they actually shipping product
393
461
  changelogAdapter({ url: repoQuery, maxLength: perSection }),
394
- // 5. What is the market pricing in
395
462
  financeAdapter({ url: ticker ?? company, maxLength: perSection }),
396
463
  ]);
397
- const section = (label, result) => result.status === "fulfilled"
398
- ? `## ${label}\n${result.value.raw}`
399
- : `## ${label}\n[Unavailable: ${result.reason}]`;
400
464
  const combined = [
401
465
  `# Company Intelligence Landscape: "${company}"${ticker ? ` (${ticker})` : ""}`,
402
466
  `Generated: ${new Date().toISOString()}`,
403
467
  `Sources: SEC EDGAR · USASpending.gov · GDELT · Changelog · Yahoo Finance`,
468
+ min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
404
469
  "",
405
- section("📋 SEC 8-K Filings — Legal Disclosures", secResult),
406
- section("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult),
407
- section("🌍 Global News Intelligence (GDELT)", gdeltResult),
408
- section("🔄 Product Release Velocity (Changelog)", changelogResult),
409
- section("📈 Market Data (Yahoo Finance)", financeResult),
410
- ].join("\n\n");
470
+ sectionWithFreshnessCheck("📋 SEC 8-K Filings — Legal Disclosures", secResult, "sec_filings", min_freshness_score),
471
+ sectionWithFreshnessCheck("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult, "govcontracts", min_freshness_score),
472
+ sectionWithFreshnessCheck("🌍 Global News Intelligence (GDELT)", gdeltResult, "gdelt", min_freshness_score),
473
+ sectionWithFreshnessCheck("🔄 Product Release Velocity (Changelog)", changelogResult, "changelog", min_freshness_score),
474
+ sectionWithFreshnessCheck("📈 Market Data (Yahoo Finance)", financeResult, "finance", min_freshness_score),
475
+ ].filter(Boolean).join("\n\n");
411
476
  return { content: [{ type: "text", text: combined }] };
412
477
  });
413
478
  // ─── Tool: extract_gebiz ────────────────────────────────────────────────────
@@ -442,37 +507,30 @@ server.registerTool("extract_idea_landscape", {
442
507
  inputSchema: z.object({
443
508
  idea: z.string().describe("Your idea, problem space, or keyword. E.g. 'data freshness for AI agents', 'procurement intelligence', 'developer observability'"),
444
509
  max_length: z.number().optional().default(14000),
510
+ min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
445
511
  }),
446
512
  annotations: { readOnlyHint: true, openWorldHint: true },
447
- }, async ({ idea, max_length }) => {
513
+ }, async ({ idea, max_length, min_freshness_score }) => {
448
514
  const perSection = Math.floor((max_length ?? 14000) / 6);
449
515
  const [hnResult, ycResult, repoResult, jobsResult, pkgResult, phResult] = await Promise.allSettled([
450
- // 1. Pain signal — what are developers actively complaining about
451
516
  hackerNewsAdapter({
452
517
  url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(idea)}&tags=story&hitsPerPage=10`,
453
518
  maxLength: perSection,
454
519
  }),
455
- // 2. Funding signal — who has already raised money in this space
456
520
  ycAdapter({
457
521
  url: `https://www.ycombinator.com/companies?query=${encodeURIComponent(idea)}`,
458
522
  maxLength: perSection,
459
523
  }),
460
- // 3. Crowding signal — how many GitHub repos exist, how active
461
524
  repoSearchAdapter({ url: idea, maxLength: perSection }),
462
- // 4. Market signal — companies paying salaries = real spend on this problem
463
525
  jobsAdapter({ url: idea, maxLength: perSection }),
464
- // 5. Ecosystem signal — npm/PyPI adoption and release velocity
465
526
  packageTrendsAdapter({ url: idea, maxLength: perSection }),
466
- // 6. Launch signal — what just shipped and how the market received it
467
527
  productHuntAdapter({ url: idea, maxLength: perSection }),
468
528
  ]);
469
- const section = (label, result) => result.status === "fulfilled"
470
- ? `## ${label}\n${result.value.raw}`
471
- : `## ${label}\n[Unavailable: ${result.reason}]`;
472
529
  const combined = [
473
530
  `# Idea Validation Landscape: "${idea}"`,
474
531
  `Generated: ${new Date().toISOString()}`,
475
532
  `Sources: Hacker News · YC Companies · GitHub · Job Listings · npm/PyPI · Product Hunt`,
533
+ min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
476
534
  "",
477
535
  `## ℹ️ How to read this report`,
478
536
  `Pain signal (HN): Are developers actively discussing this problem?`,
@@ -482,13 +540,13 @@ server.registerTool("extract_idea_landscape", {
482
540
  `Ecosystem signal (npm/PyPI): Are packages being built and adopted?`,
483
541
  `Launch signal (Product Hunt): What just shipped — community reception and timing.`,
484
542
  "",
485
- section("🗣️ Pain Signal — Developer Discussions (Hacker News)", hnResult),
486
- section("💰 Funding Signal — Backed Companies (YC)", ycResult),
487
- section("📦 Crowding Signal — Open Source Landscape (GitHub)", repoResult),
488
- section("💼 Market Signal — Hiring Activity (Job Listings)", jobsResult),
489
- section("🔧 Ecosystem Signal — Package Adoption (npm/PyPI)", pkgResult),
490
- section("🚀 Launch Signal — Recent Launches (Product Hunt)", phResult),
491
- ].join("\n\n");
543
+ sectionWithFreshnessCheck("🗣️ Pain Signal — Developer Discussions (Hacker News)", hnResult, "hackernews", min_freshness_score),
544
+ sectionWithFreshnessCheck("💰 Funding Signal — Backed Companies (YC)", ycResult, "yc", min_freshness_score),
545
+ sectionWithFreshnessCheck("📦 Crowding Signal — Open Source Landscape (GitHub)", repoResult, "reposearch", min_freshness_score),
546
+ sectionWithFreshnessCheck("💼 Market Signal — Hiring Activity (Job Listings)", jobsResult, "jobs", min_freshness_score),
547
+ sectionWithFreshnessCheck("🔧 Ecosystem Signal — Package Adoption (npm/PyPI)", pkgResult, "packagetrends", min_freshness_score),
548
+ sectionWithFreshnessCheck("🚀 Launch Signal — Recent Launches (Product Hunt)", phResult, "producthunt", min_freshness_score),
549
+ ].filter(Boolean).join("\n\n");
492
550
  return { content: [{ type: "text", text: combined }] };
493
551
  });
494
552
  // ─── Start ───────────────────────────────────────────────────────────────────
@@ -1,26 +1,34 @@
1
1
  // ─── Decay rates per adapter ──────────────────────────────────────────────────
2
- // From FreshContext Specification v1.0.
3
- // Higher decay = data goes stale faster. Half-life = 100 / (2 * decayRate) days.
4
- // finance=5.0 (half-life ~10d), jobs=3.0 (~17d), news/hn=2.0 (~25d),
5
- // github=1.0 (~50d), scholar/arxiv=0.3 (~167d), default=1.5 (~33d)
6
- const DECAY_RATES = {
7
- finance: 5.0,
8
- search_jobs: 3.0,
9
- hackernews: 2.0,
10
- reddit: 2.0,
11
- producthunt: 2.0,
12
- yc: 1.5,
13
- govcontracts: 1.5,
14
- github: 1.0,
15
- repoSearch: 1.0,
16
- packageTrends: 1.0,
17
- changelog: 1.0,
18
- scholar: 0.3,
19
- arxiv: 0.3,
2
+ // Spec-compliant exponential DAR model.
3
+ // Higher lambda = data goes stale faster. Half-life formula: = ln(2) / λ.
4
+ // Lambda is measured per hour and mirrors the Worker/D1 intelligence engine.
5
+ export const LAMBDA = {
6
+ hackernews: 0.050,
7
+ reddit: 0.010,
8
+ producthunt: 0.010,
9
+ jobs: 0.005,
10
+ finance: 0.001,
11
+ yc: 0.001,
12
+ packagetrends: 0.0005,
13
+ github: 0.0002,
14
+ reposearch: 0.0002,
15
+ google_scholar: 0.00005,
16
+ arxiv: 0.00005,
17
+ changelog: 0.0005,
18
+ gdelt: 0.020,
19
+ gebiz: 0.003,
20
+ govcontracts: 0.001,
21
+ sec_filings: 0.005,
22
+ landscape: 0.050,
23
+ gov_landscape: 0.001,
24
+ finance_landscape: 0.001,
25
+ company_landscape: 0.005,
26
+ idea_landscape: 0.050,
27
+ default: 0.001,
20
28
  };
21
29
  // ─── Score calculation ────────────────────────────────────────────────────────
22
30
  // Returns null when content_date is unknown — we can't calculate age without a date.
23
- // Returns 0 when the score would go negative (content is very old).
31
+ // Returns a clamped 0-100 exponential freshness score.
24
32
  function calculateFreshnessScore(content_date, retrieved_at, adapter) {
25
33
  if (!content_date)
26
34
  return null;
@@ -29,9 +37,9 @@ function calculateFreshnessScore(content_date, retrieved_at, adapter) {
29
37
  // Guard against unparseable dates
30
38
  if (isNaN(published) || isNaN(retrieved))
31
39
  return null;
32
- const daysSinceRetrieved = (retrieved - published) / (1000 * 60 * 60 * 24);
33
- const decayRate = DECAY_RATES[adapter] ?? 1.5;
34
- return Math.max(0, Math.round(100 - daysSinceRetrieved * decayRate));
40
+ const hoursSinceRetrieved = Math.max(0, (retrieved - published) / (1000 * 60 * 60));
41
+ const lambda = LAMBDA[adapter] ?? LAMBDA.default;
42
+ return Math.max(0, Math.round(100 * Math.exp(-lambda * hoursSinceRetrieved)));
35
43
  }
36
44
  // ─── Score label ──────────────────────────────────────────────────────────────
37
45
  // Human-readable interpretation alongside the number, per the spec.
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * freshcontext-validate
5
+ * CLI validator for FreshContext-compatible responses
6
+ * Spec: https://github.com/PrinceGabriel-lgtm/freshcontext-mcp/blob/main/FRESHCONTEXT_SPEC.md
7
+ *
8
+ * Usage:
9
+ * node freshcontext-validate.js '<json_string>'
10
+ * node freshcontext-validate.js --stdin
11
+ * node freshcontext-validate.js --file <path>
12
+ * node freshcontext-validate.js --help
13
+ *
14
+ * Compliance levels:
15
+ * FreshContext-scored ★★★ — JSON form + numeric freshness_score
16
+ * FreshContext-compatible ★★ — JSON/envelope with retrieved_at + confidence
17
+ * FreshContext-aware ★ — retrieved_at only, no confidence
18
+ * FAIL — missing required fields
19
+ */
20
+
21
+ const REQUIRED_JSON_FIELDS = ['source_url', 'retrieved_at', 'freshness_confidence', 'adapter'];
22
+ const VALID_CONFIDENCE = ['high', 'medium', 'low'];
23
+ const ENVELOPE_START = '[FRESHCONTEXT]';
24
+ const ENVELOPE_END = '[/FRESHCONTEXT]';
25
+ const REQUIRED_ENVELOPE_FIELDS = ['Source:', 'Published:', 'Retrieved:', 'Confidence:'];
26
+
27
+ const g = s => `\x1b[32m${s}\x1b[0m`;
28
+ const r = s => `\x1b[31m${s}\x1b[0m`;
29
+ const y = s => `\x1b[33m${s}\x1b[0m`;
30
+ const b = s => `\x1b[34m${s}\x1b[0m`;
31
+ const d = s => `\x1b[2m${s}\x1b[0m`;
32
+ const bold = s => `\x1b[1m${s}\x1b[0m`;
33
+
34
+ const pass = msg => ({ ok: true, warn: false, msg: ` ${g('✓')} ${msg}` });
35
+ const fail = msg => ({ ok: false, warn: false, msg: ` ${r('✕')} ${msg}` });
36
+ const warn = msg => ({ ok: true, warn: true, msg: ` ${y('!')} ${msg}` });
37
+
38
+ function validateJSON(obj) {
39
+ const results = [];
40
+ const fc = obj.freshcontext;
41
+ if (!fc) return [fail('No "freshcontext" key found in JSON response')];
42
+
43
+ for (const field of REQUIRED_JSON_FIELDS) {
44
+ if (fc[field] === undefined || fc[field] === null) {
45
+ results.push(fail(`Missing required field: freshcontext.${field}`));
46
+ } else {
47
+ results.push(pass(`freshcontext.${field} = ${d(String(fc[field]).slice(0, 80))}`));
48
+ }
49
+ }
50
+
51
+ if (fc.retrieved_at) {
52
+ const dt = new Date(fc.retrieved_at);
53
+ if (isNaN(dt.getTime())) {
54
+ results.push(fail(`retrieved_at is not valid ISO 8601: "${fc.retrieved_at}"`));
55
+ } else {
56
+ results.push(pass(`retrieved_at is valid ISO 8601`));
57
+ }
58
+ }
59
+
60
+ if (fc.freshness_confidence && !VALID_CONFIDENCE.includes(fc.freshness_confidence)) {
61
+ results.push(fail(`freshness_confidence must be high, medium, or low. Got: "${fc.freshness_confidence}"`));
62
+ }
63
+
64
+ if (fc.freshness_score !== undefined && fc.freshness_score !== null) {
65
+ if (typeof fc.freshness_score !== 'number' || fc.freshness_score < 0 || fc.freshness_score > 100) {
66
+ results.push(fail(`freshness_score must be 0-100. Got: ${fc.freshness_score}`));
67
+ } else {
68
+ const col = fc.freshness_score >= 70 ? g : fc.freshness_score >= 50 ? y : r;
69
+ results.push(pass(`freshness_score: ${col(fc.freshness_score + '/100')}`));
70
+ }
71
+ } else {
72
+ results.push(warn('freshness_score not present (optional — required for ★★★ level)'));
73
+ }
74
+
75
+ return results;
76
+ }
77
+
78
+ function validateEnvelope(text) {
79
+ const results = [];
80
+ if (!text.includes(ENVELOPE_START)) return [fail(`Missing opening tag: ${ENVELOPE_START}`)];
81
+ if (!text.includes(ENVELOPE_END)) return [fail(`Missing closing tag: ${ENVELOPE_END}`)];
82
+ results.push(pass('Envelope tags present'));
83
+
84
+ const start = text.indexOf(ENVELOPE_START) + ENVELOPE_START.length;
85
+ const end = text.indexOf(ENVELOPE_END);
86
+ const envelope = text.slice(start, end);
87
+
88
+ for (const field of REQUIRED_ENVELOPE_FIELDS) {
89
+ if (!envelope.includes(field)) {
90
+ results.push(fail(`Missing field: ${field}`));
91
+ } else {
92
+ const line = envelope.split('\n').find(l => l.trim().startsWith(field));
93
+ results.push(pass(`${field.replace(':', '')} — ${d((line || '').trim().slice(0, 70))}`));
94
+ }
95
+ }
96
+
97
+ const confLine = envelope.split('\n').find(l => l.trim().startsWith('Confidence:'));
98
+ if (confLine) {
99
+ const confVal = confLine.split(':')[1]?.trim().toLowerCase();
100
+ if (!VALID_CONFIDENCE.includes(confVal)) {
101
+ results.push(fail(`Confidence must be high, medium, or low. Got: "${confVal}"`));
102
+ }
103
+ }
104
+
105
+ return results;
106
+ }
107
+
108
+ function complianceLevel(results, hasScore, mode) {
109
+ const failed = results.filter(res => !res.ok).length;
110
+ if (failed > 0) return { level: 'FAIL', colour: r };
111
+ if (mode === 'json' && hasScore) return { level: 'FreshContext-scored \u2605\u2605\u2605', colour: g };
112
+ return { level: 'FreshContext-compatible \u2605\u2605', colour: g };
113
+ }
114
+
115
+ function validateString(input, label) {
116
+ console.log(`\n${bold('freshcontext-validate')} ${d('v1.0.0')}`);
117
+ console.log(d('\u2500'.repeat(52)));
118
+ console.log(`${b('Input:')} ${d(label)}\n`);
119
+
120
+ let results = [];
121
+ let hasScore = false;
122
+ let mode = 'envelope';
123
+
124
+ try {
125
+ const parsed = JSON.parse(input);
126
+ mode = 'json';
127
+ console.log(`${d('Mode:')} ${b('JSON structured form')}\n`);
128
+ results = validateJSON(parsed);
129
+ hasScore = parsed?.freshcontext?.freshness_score !== undefined
130
+ && parsed?.freshcontext?.freshness_score !== null;
131
+ } catch {
132
+ if (input.includes(ENVELOPE_START)) {
133
+ console.log(`${d('Mode:')} ${b('Text envelope')}\n`);
134
+ results = validateEnvelope(input);
135
+ } else {
136
+ console.log(r('✕ Input is neither valid JSON nor a FreshContext text envelope.\n'));
137
+ console.log(d('Expected:'));
138
+ console.log(` ${d('JSON:')} {"freshcontext": {"source_url": "...", "retrieved_at": "...", ...}}`);
139
+ console.log(` ${d('Envelope:')} [FRESHCONTEXT]\\nSource: ...\\n[/FRESHCONTEXT]`);
140
+ process.exit(1);
141
+ }
142
+ }
143
+
144
+ results.forEach(res => console.log(res.msg));
145
+
146
+ const passed = results.filter(res => res.ok && !res.warn).length;
147
+ const failed = results.filter(res => !res.ok).length;
148
+ const warned = results.filter(res => res.warn).length;
149
+
150
+ const { level, colour } = complianceLevel(results, hasScore, mode);
151
+
152
+ console.log(`\n${d('\u2500'.repeat(52))}`);
153
+ console.log(`${d('Checks:')} ${g(passed + ' passed')}${warned ? ', ' + y(warned + ' warnings') : ''}${failed ? ', ' + r(failed + ' failed') : ''}`);
154
+ console.log(`${d('Result:')} ${colour(bold(level))}\n`);
155
+
156
+ if (failed > 0) process.exit(1);
157
+ }
158
+
159
+ const args = process.argv.slice(2);
160
+
161
+ if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
162
+ console.log(`
163
+ ${bold('freshcontext-validate')} ${d('v1.0.0')}
164
+ Validates FreshContext-compatible responses against the spec.
165
+
166
+ ${bold('USAGE')}
167
+ node freshcontext-validate.js ${d('<json_or_envelope_string>')}
168
+ node freshcontext-validate.js --file ${d('<path>')}
169
+ node freshcontext-validate.js --stdin
170
+ echo '...' | node freshcontext-validate.js --stdin
171
+
172
+ ${bold('COMPLIANCE LEVELS')}
173
+ ${g('FreshContext-scored \u2605\u2605\u2605')} Full JSON form + numeric freshness_score
174
+ ${g('FreshContext-compatible \u2605\u2605')} JSON/envelope with retrieved_at + confidence
175
+ ${r('FAIL')} Missing required fields
176
+
177
+ ${bold('SPEC')}
178
+ https://freshcontext-site.pages.dev/spec.html
179
+ https://github.com/PrinceGabriel-lgtm/freshcontext-mcp/blob/main/FRESHCONTEXT_SPEC.md
180
+ `);
181
+ process.exit(0);
182
+ }
183
+
184
+ if (args[0] === '--stdin') {
185
+ let data = '';
186
+ process.stdin.setEncoding('utf8');
187
+ process.stdin.on('data', chunk => data += chunk);
188
+ process.stdin.on('end', () => validateString(data.trim(), 'stdin'));
189
+ } else if (args[0] === '--file') {
190
+ const path = args[1];
191
+ if (!path) { console.error(r('--file requires a path argument')); process.exit(1); }
192
+ const fs = require('fs');
193
+ validateString(fs.readFileSync(path, 'utf8').trim(), path);
194
+ } else {
195
+ validateString(args[0], 'inline input');
196
+ }