freshcontext-mcp 0.3.13 → 0.3.15

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
@@ -9,6 +9,7 @@ import { ycAdapter } from "./adapters/yc.js";
9
9
  import { repoSearchAdapter } from "./adapters/repoSearch.js";
10
10
  import { packageTrendsAdapter } from "./adapters/packageTrends.js";
11
11
  import { redditAdapter } from "./adapters/reddit.js";
12
+ import { productHuntAdapter } from "./adapters/productHunt.js";
12
13
  import { financeAdapter } from "./adapters/finance.js";
13
14
  import { jobsAdapter } from "./adapters/jobs.js";
14
15
  import { changelogAdapter } from "./adapters/changelog.js";
@@ -130,6 +131,18 @@ server.registerTool("package_trends", {
130
131
  return { content: [{ type: "text", text: formatSecurityError(err) }] };
131
132
  }
132
133
  });
134
+ function sectionWithFreshnessCheck(label, result, adapterName, minScore, errorWord = "Unavailable") {
135
+ if (result.status !== "fulfilled") {
136
+ return `## ${label}\n[${errorWord}: ${result.reason}]`;
137
+ }
138
+ if (minScore !== undefined && minScore > 0) {
139
+ const ctx = stampFreshness(result.value, { url: "", maxLength: 0 }, adapterName);
140
+ if (ctx.freshness_score !== null && ctx.freshness_score < minScore) {
141
+ 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.]`;
142
+ }
143
+ }
144
+ return `## ${label}\n${result.value.raw}`;
145
+ }
133
146
  // ─── Tool: extract_landscape ─────────────────────────────────────────────────
134
147
  server.registerTool("extract_landscape", {
135
148
  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.",
@@ -236,37 +249,31 @@ server.registerTool("extract_gov_landscape", {
236
249
  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."),
237
250
  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."),
238
251
  max_length: z.number().optional().default(12000),
252
+ min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
239
253
  }),
240
254
  annotations: { readOnlyHint: true, openWorldHint: true },
241
- }, async ({ query, github_url, max_length }) => {
255
+ }, async ({ query, github_url, max_length, min_freshness_score }) => {
242
256
  const perSection = Math.floor((max_length ?? 12000) / 4);
243
- // All four sources fire in parallel — if one fails the others still return
244
257
  const [contractsResult, hnResult, repoResult, changelogResult] = await Promise.allSettled([
245
- // 1. The anchor: who is actually winning federal money in this space
246
258
  govContractsAdapter({ url: query, maxLength: perSection }),
247
- // 2. Dev community signal: does anyone in tech know about these companies
248
259
  hackerNewsAdapter({
249
260
  url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}&tags=story&hitsPerPage=10`,
250
261
  maxLength: perSection,
251
262
  }),
252
- // 3. GitHub activity: are they actually building, or contract farmers
253
263
  repoSearchAdapter({ url: github_url ?? query, maxLength: perSection }),
254
- // 4. Release velocity: how fast are they shipping product
255
264
  changelogAdapter({ url: github_url ?? query, maxLength: perSection }),
256
265
  ]);
257
- const section = (label, result) => result.status === "fulfilled"
258
- ? `## ${label}\n${result.value.raw}`
259
- : `## ${label}\n[Unavailable: ${result.reason}]`;
260
266
  const combined = [
261
267
  `# Government Intelligence Landscape: "${query}"`,
262
268
  `Generated: ${new Date().toISOString()}`,
263
269
  `Sources: USASpending.gov · Hacker News · GitHub · Changelog`,
270
+ min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
264
271
  "",
265
- section("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult),
266
- section("💬 Developer Community Awareness (Hacker News)", hnResult),
267
- section("📦 GitHub Repository Activity", repoResult),
268
- section("🔄 Product Release Velocity (Changelog)", changelogResult),
269
- ].join("\n\n");
272
+ sectionWithFreshnessCheck("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult, "govcontracts", min_freshness_score),
273
+ sectionWithFreshnessCheck("💬 Developer Community Awareness (Hacker News)", hnResult, "hackernews", min_freshness_score),
274
+ sectionWithFreshnessCheck("📦 GitHub Repository Activity", repoResult, "github_search", min_freshness_score),
275
+ sectionWithFreshnessCheck("🔄 Product Release Velocity (Changelog)", changelogResult, "changelog", min_freshness_score),
276
+ ].filter(Boolean).join("\n\n");
270
277
  return { content: [{ type: "text", text: combined }] };
271
278
  });
272
279
  // ─── Tool: extract_finance_landscape ─────────────────────────────────────────
@@ -281,43 +288,35 @@ server.registerTool("extract_finance_landscape", {
281
288
  company_name: z.string().optional().describe("Company name for HN/Reddit/GitHub searches e.g. 'Palantir'. If omitted, derived from the ticker."),
282
289
  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."),
283
290
  max_length: z.number().optional().default(12000),
291
+ min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
284
292
  }),
285
293
  annotations: { readOnlyHint: true, openWorldHint: true },
286
- }, async ({ tickers, company_name, github_query, max_length }) => {
294
+ }, async ({ tickers, company_name, github_query, max_length, min_freshness_score }) => {
287
295
  const perSection = Math.floor((max_length ?? 12000) / 5);
288
- // Derive a search term: prefer explicit company_name, fall back to first ticker
289
296
  const searchTerm = company_name ?? tickers.split(",")[0].trim();
290
297
  const repoQuery = github_query ?? searchTerm;
291
- // All five sources fire in parallel
292
298
  const [priceResult, hnResult, redditResult, repoResult, changelogResult] = await Promise.allSettled([
293
- // 1. The anchor: live price, market cap, P/E, 52w range
294
299
  financeAdapter({ url: tickers, maxLength: perSection }),
295
- // 2. Developer sentiment: what engineers think of this company's tech
296
300
  hackerNewsAdapter({
297
301
  url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(searchTerm)}&tags=story&hitsPerPage=10`,
298
302
  maxLength: perSection,
299
303
  }),
300
- // 3. Broader community: investor and tech community discussion on Reddit
301
304
  redditAdapter({ url: `https://www.reddit.com/search.json?q=${encodeURIComponent(searchTerm)}&sort=new&limit=15`, maxLength: perSection }),
302
- // 4. Repo ecosystem: how many GitHub projects orbit this company's technology
303
305
  repoSearchAdapter({ url: repoQuery, maxLength: perSection }),
304
- // 5. Release velocity: is the company actually shipping product right now
305
306
  changelogAdapter({ url: repoQuery, maxLength: perSection }),
306
307
  ]);
307
- const section = (label, result) => result.status === "fulfilled"
308
- ? `## ${label}\n${result.value.raw}`
309
- : `## ${label}\n[Unavailable: ${result.reason}]`;
310
308
  const combined = [
311
309
  `# Finance + Developer Intelligence: "${tickers}"${company_name ? ` (${company_name})` : ""}`,
312
310
  `Generated: ${new Date().toISOString()}`,
313
311
  `Sources: Yahoo Finance · Hacker News · Reddit · GitHub · Changelog`,
312
+ min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
314
313
  "",
315
- section("📈 Market Data (Yahoo Finance)", priceResult),
316
- section("💬 Developer Sentiment (Hacker News)", hnResult),
317
- section("🗣️ Community Discussion (Reddit)", redditResult),
318
- section("📦 Repo Ecosystem (GitHub)", repoResult),
319
- section("🔄 Product Release Velocity (Changelog)", changelogResult),
320
- ].join("\n\n");
314
+ sectionWithFreshnessCheck("📈 Market Data (Yahoo Finance)", priceResult, "finance", min_freshness_score),
315
+ sectionWithFreshnessCheck("💬 Developer Sentiment (Hacker News)", hnResult, "hackernews", min_freshness_score),
316
+ sectionWithFreshnessCheck("🗣️ Community Discussion (Reddit)", redditResult, "reddit", min_freshness_score),
317
+ sectionWithFreshnessCheck("📦 Repo Ecosystem (GitHub)", repoResult, "github_search", min_freshness_score),
318
+ sectionWithFreshnessCheck("🔄 Product Release Velocity (Changelog)", changelogResult, "changelog", min_freshness_score),
319
+ ].filter(Boolean).join("\n\n");
321
320
  return { content: [{ type: "text", text: combined }] };
322
321
  });
323
322
  // ─── Tool: extract_sec_filings ─────────────────────────────────────────────
@@ -376,37 +375,31 @@ server.registerTool("extract_company_landscape", {
376
375
  ticker: z.string().optional().describe("Stock ticker for finance data e.g. 'PLTR'. Leave blank for private companies."),
377
376
  github_url: z.string().optional().describe("Optional GitHub repo or org URL e.g. 'https://github.com/palantir'. Improves changelog accuracy."),
378
377
  max_length: z.number().optional().default(15000),
378
+ min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
379
379
  }),
380
380
  annotations: { readOnlyHint: true, openWorldHint: true },
381
- }, async ({ company, ticker, github_url, max_length }) => {
381
+ }, async ({ company, ticker, github_url, max_length, min_freshness_score }) => {
382
382
  const perSection = Math.floor((max_length ?? 15000) / 5);
383
383
  const repoQuery = github_url ?? company;
384
384
  const [secResult, contractsResult, gdeltResult, changelogResult, financeResult] = await Promise.allSettled([
385
- // 1. What did they legally just disclose
386
385
  secFilingsAdapter({ url: company, maxLength: perSection }),
387
- // 2. Who is giving them government money
388
386
  govContractsAdapter({ url: company, maxLength: perSection }),
389
- // 3. What is global news saying right now
390
387
  gdeltAdapter({ url: company, maxLength: perSection }),
391
- // 4. Are they actually shipping product
392
388
  changelogAdapter({ url: repoQuery, maxLength: perSection }),
393
- // 5. What is the market pricing in
394
389
  financeAdapter({ url: ticker ?? company, maxLength: perSection }),
395
390
  ]);
396
- const section = (label, result) => result.status === "fulfilled"
397
- ? `## ${label}\n${result.value.raw}`
398
- : `## ${label}\n[Unavailable: ${result.reason}]`;
399
391
  const combined = [
400
392
  `# Company Intelligence Landscape: "${company}"${ticker ? ` (${ticker})` : ""}`,
401
393
  `Generated: ${new Date().toISOString()}`,
402
394
  `Sources: SEC EDGAR · USASpending.gov · GDELT · Changelog · Yahoo Finance`,
395
+ min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
403
396
  "",
404
- section("📋 SEC 8-K Filings — Legal Disclosures", secResult),
405
- section("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult),
406
- section("🌍 Global News Intelligence (GDELT)", gdeltResult),
407
- section("🔄 Product Release Velocity (Changelog)", changelogResult),
408
- section("📈 Market Data (Yahoo Finance)", financeResult),
409
- ].join("\n\n");
397
+ sectionWithFreshnessCheck("📋 SEC 8-K Filings — Legal Disclosures", secResult, "sec_filings", min_freshness_score),
398
+ sectionWithFreshnessCheck("🏛️ Federal Contract Awards (USASpending.gov)", contractsResult, "govcontracts", min_freshness_score),
399
+ sectionWithFreshnessCheck("🌍 Global News Intelligence (GDELT)", gdeltResult, "gdelt", min_freshness_score),
400
+ sectionWithFreshnessCheck("🔄 Product Release Velocity (Changelog)", changelogResult, "changelog", min_freshness_score),
401
+ sectionWithFreshnessCheck("📈 Market Data (Yahoo Finance)", financeResult, "finance", min_freshness_score),
402
+ ].filter(Boolean).join("\n\n");
410
403
  return { content: [{ type: "text", text: combined }] };
411
404
  });
412
405
  // ─── Tool: extract_gebiz ────────────────────────────────────────────────────
@@ -430,6 +423,59 @@ server.registerTool("extract_gebiz", {
430
423
  return { content: [{ type: "text", text: formatSecurityError(err) }] };
431
424
  }
432
425
  });
426
+ // ─── Tool: extract_idea_landscape ───────────────────────────────────────────
427
+ // Idea validation composite — 6 sources that answer: should I build this?
428
+ // HN pain points + YC funded competitors + GitHub crowding + job market signal
429
+ // + package ecosystem adoption + Product Hunt recent launches.
430
+ // The job market section is the key differentiator — companies paying salaries
431
+ // around a problem is the strongest signal a real market exists.
432
+ server.registerTool("extract_idea_landscape", {
433
+ description: "Idea validation composite tool for developers and founders. Given a project idea or keyword, simultaneously queries 6 sources to answer: Is this problem real? Is the market crowded? Is there funding? Are companies hiring? What just launched? Sources: (1) Hacker News — what developers are actively complaining about and discussing, (2) YC companies — who has already received funding in this space, (3) GitHub repos — how crowded the open source landscape is, (4) Job listings — hiring signal showing real company spend around this problem, (5) npm/PyPI package trends — ecosystem adoption and velocity, (6) Product Hunt — what just launched and how it was received. Returns a unified 6-source idea validation report.",
434
+ inputSchema: z.object({
435
+ idea: z.string().describe("Your idea, problem space, or keyword. E.g. 'data freshness for AI agents', 'procurement intelligence', 'developer observability'"),
436
+ max_length: z.number().optional().default(14000),
437
+ min_freshness_score: z.number().optional().describe("Filter sections below this freshness_score (0–100). E.g. 70 = only recently retrieved data."),
438
+ }),
439
+ annotations: { readOnlyHint: true, openWorldHint: true },
440
+ }, async ({ idea, max_length, min_freshness_score }) => {
441
+ const perSection = Math.floor((max_length ?? 14000) / 6);
442
+ const [hnResult, ycResult, repoResult, jobsResult, pkgResult, phResult] = await Promise.allSettled([
443
+ hackerNewsAdapter({
444
+ url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(idea)}&tags=story&hitsPerPage=10`,
445
+ maxLength: perSection,
446
+ }),
447
+ ycAdapter({
448
+ url: `https://www.ycombinator.com/companies?query=${encodeURIComponent(idea)}`,
449
+ maxLength: perSection,
450
+ }),
451
+ repoSearchAdapter({ url: idea, maxLength: perSection }),
452
+ jobsAdapter({ url: idea, maxLength: perSection }),
453
+ packageTrendsAdapter({ url: idea, maxLength: perSection }),
454
+ productHuntAdapter({ url: idea, maxLength: perSection }),
455
+ ]);
456
+ const combined = [
457
+ `# Idea Validation Landscape: "${idea}"`,
458
+ `Generated: ${new Date().toISOString()}`,
459
+ `Sources: Hacker News · YC Companies · GitHub · Job Listings · npm/PyPI · Product Hunt`,
460
+ min_freshness_score ? `min_freshness_score: ${min_freshness_score}` : null,
461
+ "",
462
+ `## ℹ️ How to read this report`,
463
+ `Pain signal (HN): Are developers actively discussing this problem?`,
464
+ `Funding signal (YC): Has this already attracted institutional money?`,
465
+ `Crowding signal (GitHub): How many repos exist — empty = opportunity, crowded = validation.`,
466
+ `Market signal (Jobs): Companies hiring around this = real budget allocated = real market.`,
467
+ `Ecosystem signal (npm/PyPI): Are packages being built and adopted?`,
468
+ `Launch signal (Product Hunt): What just shipped — community reception and timing.`,
469
+ "",
470
+ sectionWithFreshnessCheck("🗣️ Pain Signal — Developer Discussions (Hacker News)", hnResult, "hackernews", min_freshness_score),
471
+ sectionWithFreshnessCheck("💰 Funding Signal — Backed Companies (YC)", ycResult, "ycombinator", min_freshness_score),
472
+ sectionWithFreshnessCheck("📦 Crowding Signal — Open Source Landscape (GitHub)", repoResult, "github_search", min_freshness_score),
473
+ sectionWithFreshnessCheck("💼 Market Signal — Hiring Activity (Job Listings)", jobsResult, "jobs", min_freshness_score),
474
+ sectionWithFreshnessCheck("🔧 Ecosystem Signal — Package Adoption (npm/PyPI)", pkgResult, "package_registry", min_freshness_score),
475
+ sectionWithFreshnessCheck("🚀 Launch Signal — Recent Launches (Product Hunt)", phResult, "producthunt", min_freshness_score),
476
+ ].filter(Boolean).join("\n\n");
477
+ return { content: [{ type: "text", text: combined }] };
478
+ });
433
479
  // ─── Start ───────────────────────────────────────────────────────────────────
434
480
  async function main() {
435
481
  const transport = new StdioServerTransport();
@@ -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
+ }
@@ -0,0 +1,103 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://freshcontext-site.pages.dev/freshcontext.schema.json",
4
+ "title": "FreshContext",
5
+ "description": "The FreshContext Specification v1.1 — structured envelope for AI-retrieved web data. https://github.com/PrinceGabriel-lgtm/freshcontext-mcp",
6
+ "type": "object",
7
+ "required": ["freshcontext"],
8
+ "properties": {
9
+ "freshcontext": {
10
+ "type": "object",
11
+ "description": "The freshness metadata envelope for a single retrieved result.",
12
+ "required": ["source_url", "retrieved_at", "freshness_confidence", "adapter"],
13
+ "properties": {
14
+ "source_url": {
15
+ "type": "string",
16
+ "format": "uri",
17
+ "description": "The canonical URL of the original source from which content was retrieved."
18
+ },
19
+ "content_date": {
20
+ "type": ["string", "null"],
21
+ "description": "Best estimate of when the content was originally published. ISO 8601 date (YYYY-MM-DD) or ISO 8601 datetime. Null if unknown.",
22
+ "examples": ["2026-03-05", "2026-03-05T09:19:00.000Z", null]
23
+ },
24
+ "retrieved_at": {
25
+ "type": "string",
26
+ "format": "date-time",
27
+ "description": "Exact ISO 8601 datetime (with timezone) when this data was fetched.",
28
+ "examples": ["2026-04-05T09:19:00.000Z"]
29
+ },
30
+ "freshness_confidence": {
31
+ "type": "string",
32
+ "enum": ["high", "medium", "low"],
33
+ "description": "Confidence level of the content_date estimate. 'high' = structured API field. 'medium' = inferred from page signals. 'low' = unknown or estimated."
34
+ },
35
+ "freshness_score": {
36
+ "type": ["number", "null"],
37
+ "minimum": 0,
38
+ "maximum": 100,
39
+ "description": "Optional numeric freshness score 0-100. Calculated as: max(0, 100 - (days_since_retrieved * decay_rate)). Null if content_date is unknown.",
40
+ "examples": [94, 72, 45, null]
41
+ },
42
+ "adapter": {
43
+ "type": "string",
44
+ "description": "Identifier of the adapter (data source) that produced this result.",
45
+ "examples": [
46
+ "github",
47
+ "hackernews",
48
+ "google_scholar",
49
+ "arxiv",
50
+ "reddit",
51
+ "ycombinator",
52
+ "producthunt",
53
+ "github_search",
54
+ "package_registry",
55
+ "finance",
56
+ "jobs",
57
+ "changelog",
58
+ "govcontracts",
59
+ "sec_filings",
60
+ "gdelt",
61
+ "gebiz"
62
+ ]
63
+ },
64
+ "decay_rate": {
65
+ "type": ["number", "null"],
66
+ "description": "The decay rate used to calculate freshness_score. Domain-specific. Financial=5.0, jobs=3.0, news=2.0, github=1.0, academic=0.3, default=1.5.",
67
+ "examples": [5.0, 3.0, 2.0, 1.5, 1.0, 0.3, null]
68
+ }
69
+ },
70
+ "additionalProperties": false
71
+ },
72
+ "content": {
73
+ "type": "string",
74
+ "description": "The retrieved content — raw text, structured data, or formatted output from the adapter."
75
+ }
76
+ },
77
+ "examples": [
78
+ {
79
+ "freshcontext": {
80
+ "source_url": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp",
81
+ "content_date": "2026-04-05",
82
+ "retrieved_at": "2026-04-05T09:19:00.000Z",
83
+ "freshness_confidence": "high",
84
+ "freshness_score": 94,
85
+ "adapter": "github",
86
+ "decay_rate": 1.0
87
+ },
88
+ "content": "freshcontext-mcp — Real-time web intelligence for AI agents..."
89
+ },
90
+ {
91
+ "freshcontext": {
92
+ "source_url": "https://efts.sec.gov/LATEST/search-index?q=Palantir&forms=8-K",
93
+ "content_date": "2026-02-03",
94
+ "retrieved_at": "2026-04-05T09:19:00.000Z",
95
+ "freshness_confidence": "high",
96
+ "freshness_score": 38,
97
+ "adapter": "sec_filings",
98
+ "decay_rate": 1.5
99
+ },
100
+ "content": "Palantir Technologies — 8-K filing: Q4 2025 earnings..."
101
+ }
102
+ ]
103
+ }
package/input_schema.json CHANGED
@@ -1,45 +1,44 @@
1
1
  {
2
- "title": "FreshContext MCP Input",
2
+ "title": "FreshContext Input",
3
3
  "type": "object",
4
4
  "schemaVersion": 1,
5
5
  "properties": {
6
6
  "tool": {
7
7
  "title": "Tool",
8
8
  "type": "string",
9
- "description": "The FreshContext tool to run.",
9
+ "description": "The FreshContext tool to run. See README for full descriptions.",
10
10
  "enum": [
11
- "extract_github",
12
11
  "extract_hackernews",
13
- "extract_scholar",
12
+ "extract_github",
13
+ "extract_govcontracts",
14
+ "extract_sec_filings",
15
+ "extract_gdelt",
16
+ "extract_gebiz",
17
+ "extract_finance",
18
+ "extract_changelog",
14
19
  "extract_arxiv",
20
+ "extract_scholar",
15
21
  "extract_reddit",
16
22
  "extract_yc",
17
23
  "extract_producthunt",
18
24
  "search_repos",
19
25
  "package_trends",
20
- "extract_finance",
21
- "extract_landscape"
26
+ "search_jobs"
22
27
  ],
23
- "default": "extract_landscape",
28
+ "default": "extract_hackernews",
24
29
  "editor": "select"
25
30
  },
26
31
  "url": {
27
- "title": "URL",
28
- "type": "string",
29
- "description": "URL to extract from. Required for: extract_github, extract_hackernews, extract_scholar, extract_reddit. E.g. https://github.com/owner/repo",
30
- "editor": "textfield"
31
- },
32
- "query": {
33
- "title": "Query",
32
+ "title": "URL or Query",
34
33
  "type": "string",
35
- "description": "Search query. Required for: extract_landscape, search_repos, extract_yc, extract_producthunt, package_trends, extract_finance.",
34
+ "description": "The main input for the tool. For extract_github: full GitHub URL. For extract_hackernews: HN URL or search URL. For extract_govcontracts/extract_sec_filings/extract_gdelt/extract_gebiz/extract_finance: company name, keyword, or ticker. For search_repos/package_trends/search_jobs: search keyword.",
36
35
  "editor": "textfield"
37
36
  },
38
37
  "max_length": {
39
38
  "title": "Max content length",
40
39
  "type": "integer",
41
- "description": "Maximum characters returned per result. Default: 6000.",
42
- "default": 6000,
40
+ "description": "Maximum characters returned. Default: 8000.",
41
+ "default": 8000,
43
42
  "minimum": 500,
44
43
  "maximum": 20000,
45
44
  "editor": "number"
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "freshcontext-mcp",
3
3
  "mcpName": "io.github.PrinceGabriel-lgtm/freshcontext",
4
- "version": "0.3.13",
5
- "description": "Real-time web extraction MCP server with freshness timestamps for AI agents",
4
+ "version": "0.3.15",
5
+ "description": "Real-time web intelligence for AI agents. 20 tools, no API keys. Every result timestamped with a freshness score.",
6
6
  "keywords": [
7
7
  "mcp",
8
8
  "mcp-server",
package/server.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
3
3
  "name": "io.github.PrinceGabriel-lgtm/freshcontext",
4
- "description": "Real-time web intelligence for AI agents. 15 tools, no API keys. GitHub, HN, Reddit, arXiv, govcontracts, changelog, gov landscape & finance landscape — every result timestamped with a freshness score.",
4
+ "description": "Real-time web intelligence for AI agents. 20 tools, no API keys. GitHub, HN, Reddit, arXiv, SEC filings, US gov contracts, GDELT global news, Singapore GeBIZ, changelog & more — every result timestamped with a freshness score.",
5
5
  "repository": {
6
6
  "url": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "0.3.11",
9
+ "version": "0.3.15",
10
10
  "website_url": "https://freshcontext-site.pages.dev",
11
11
  "packages": [
12
12
  {
13
13
  "registry_type": "npm",
14
14
  "registry_base_url": "https://registry.npmjs.org",
15
15
  "identifier": "freshcontext-mcp",
16
- "version": "0.3.11",
16
+ "version": "0.3.15",
17
17
  "transport": {
18
18
  "type": "stdio"
19
19
  }