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/.actor/Dockerfile +7 -4
- package/.actor/actor.json +1 -1
- package/CONTEXT_SKILL.md +84 -0
- package/FRESHCONTEXT_SPEC.md +80 -6
- package/HANDOFF.md +220 -91
- package/METHODOLOGY.md +277 -0
- package/README.md +195 -41
- package/SESSION_SAVE_V5.md +121 -0
- package/SESSION_SAVE_V6.md +194 -0
- package/SESSION_SAVE_V9.md +170 -0
- package/dist/apify.js +133 -0
- package/dist/server.js +92 -46
- package/freshcontext-validate.js +196 -0
- package/freshcontext.schema.json +103 -0
- package/input_schema.json +16 -17
- package/package.json +2 -2
- package/server.json +3 -3
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
21
|
-
"extract_landscape"
|
|
26
|
+
"search_jobs"
|
|
22
27
|
],
|
|
23
|
-
"default": "
|
|
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": "
|
|
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
|
|
42
|
-
"default":
|
|
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.
|
|
5
|
-
"description": "Real-time web
|
|
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.
|
|
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.
|
|
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.
|
|
16
|
+
"version": "0.3.15",
|
|
17
17
|
"transport": {
|
|
18
18
|
"type": "stdio"
|
|
19
19
|
}
|