freshcontext-mcp 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -21,14 +21,14 @@ Every piece of data extracted by `freshcontext-mcp` is wrapped in a structured e
21
21
  [FRESHCONTEXT]
22
22
  Source: https://github.com/owner/repo
23
23
  Published: 2024-11-03
24
- Retrieved: 2026-03-03T10:14:00Z
24
+ Retrieved: 2026-03-04T10:14:00Z
25
25
  Confidence: high
26
26
  ---
27
27
  ... content ...
28
28
  [/FRESHCONTEXT]
29
29
  ```
30
30
 
31
- The AI agent always knows **when it's looking at data**, not just what the data says. This is the difference between a hallucinated recency claim and a verifiable one.
31
+ The AI agent always knows **when it's looking at data**, not just what the data says.
32
32
 
33
33
  ---
34
34
 
@@ -60,13 +60,33 @@ The AI agent always knows **when it's looking at data**, not just what the data
60
60
 
61
61
  ## Quick Start
62
62
 
63
- ### Install via npm
63
+ ### Option A — Cloud (no install, works immediately)
64
64
 
65
- ```bash
66
- npx freshcontext-mcp
65
+ No Node, no Playwright, nothing to install. Just add this to your Claude Desktop config and restart.
66
+
67
+ **Mac:** open `~/Library/Application Support/Claude/claude_desktop_config.json`
68
+ **Windows:** open `%APPDATA%\Claude\claude_desktop_config.json`
69
+
70
+ ```json
71
+ {
72
+ "mcpServers": {
73
+ "freshcontext": {
74
+ "command": "npx",
75
+ "args": ["-y", "mcp-remote", "https://freshcontext-mcp.gimmanuel73.workers.dev/mcp"]
76
+ }
77
+ }
78
+ }
67
79
  ```
68
80
 
69
- ### Or clone and run locally
81
+ Restart Claude Desktop. The freshcontext tools will appear in your session.
82
+
83
+ > **Note:** If `claude_desktop_config.json` doesn't exist yet, create it with the content above.
84
+
85
+ ---
86
+
87
+ ### Option B — Local (full Playwright, faster for heavy use)
88
+
89
+ **Prerequisites:** Node.js 18+ ([nodejs.org](https://nodejs.org))
70
90
 
71
91
  ```bash
72
92
  git clone https://github.com/PrinceGabriel-lgtm/freshcontext-mcp
@@ -76,39 +96,56 @@ npx playwright install chromium
76
96
  npm run build
77
97
  ```
78
98
 
79
- ### Connect to Claude Desktop
80
-
81
- Add to your `claude_desktop_config.json`:
82
-
83
- **Mac:** `~/Library/Application Support/Claude/claude_desktop_config.json`
84
- **Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
99
+ Then add to your Claude Desktop config:
85
100
 
101
+ **Mac** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
86
102
  ```json
87
103
  {
88
104
  "mcpServers": {
89
- "freshcontext-local": {
105
+ "freshcontext": {
90
106
  "command": "node",
91
- "args": ["/absolute/path/to/freshcontext-mcp/dist/server.js"]
107
+ "args": ["/Users/YOUR_USERNAME/path/to/freshcontext-mcp/dist/server.js"]
92
108
  }
93
109
  }
94
110
  }
95
111
  ```
96
112
 
97
- Restart Claude Desktop. You'll see the freshcontext tools available in your session.
98
-
99
- ### Or use the Cloudflare edge deployment (no install needed)
100
-
113
+ **Windows** (`%APPDATA%\Claude\claude_desktop_config.json`):
101
114
  ```json
102
115
  {
103
116
  "mcpServers": {
104
- "freshcontext-cloud": {
105
- "command": "npx",
106
- "args": ["-y", "mcp-remote", "https://freshcontext-worker.gimmanuel73.workers.dev/mcp"]
117
+ "freshcontext": {
118
+ "command": "node",
119
+ "args": ["C:\\Users\\YOUR_USERNAME\\path\\to\\freshcontext-mcp\\dist\\server.js"]
107
120
  }
108
121
  }
109
122
  }
110
123
  ```
111
124
 
125
+ Restart Claude Desktop.
126
+
127
+ ---
128
+
129
+ ### Troubleshooting (Mac)
130
+
131
+ **"command not found: node"** — Node isn't on your PATH inside Claude Desktop's environment. Use the full path:
132
+ ```bash
133
+ which node # copy this output
134
+ ```
135
+ Then replace `"command": "node"` with `"command": "/usr/local/bin/node"` (or whatever `which node` returned).
136
+
137
+ **"npx: command not found"** — Same issue. Run `which npx` and use the full path for Option A:
138
+ ```json
139
+ "command": "/usr/local/bin/npx"
140
+ ```
141
+
142
+ **Config file doesn't exist** — Create it. On Mac:
143
+ ```bash
144
+ mkdir -p ~/Library/Application\ Support/Claude
145
+ touch ~/Library/Application\ Support/Claude/claude_desktop_config.json
146
+ ```
147
+ Then paste the config JSON above into it.
148
+
112
149
  ---
113
150
 
114
151
  ## Usage Examples
@@ -162,12 +199,12 @@ This makes freshness **verifiable**, not assumed.
162
199
  Uses headless Chromium via Playwright. Full browser rendering for JavaScript-heavy sites.
163
200
 
164
201
  ### Cloud (Cloudflare Workers)
165
- The `worker/` directory contains a Cloudflare Workers deployment using the Browser Rendering REST API. No Playwright dependency — runs at the edge globally.
202
+ The `worker/` directory contains a Cloudflare Workers deployment. No Playwright dependency — runs at the edge globally.
166
203
 
167
204
  ```bash
168
205
  cd worker
169
206
  npm install
170
- npx wrangler secret put CF_API_TOKEN
207
+ npx wrangler secret put API_KEY
171
208
  npx wrangler deploy
172
209
  ```
173
210
 
@@ -180,15 +217,16 @@ freshcontext-mcp/
180
217
  ├── src/
181
218
  │ ├── server.ts # MCP server, all tool registrations
182
219
  │ ├── types.ts # FreshContext interfaces
220
+ │ ├── security.ts # Input validation, domain allowlists
183
221
  │ ├── adapters/
184
- │ │ ├── github.ts # GitHub repo extraction
185
- │ │ ├── hackernews.ts # HN front page + Algolia API
186
- │ │ ├── scholar.ts # Google Scholar scraping
187
- │ │ ├── yc.ts # YC company directory
188
- │ │ ├── repoSearch.ts # GitHub Search API
189
- │ │ └── packageTrends.ts # npm + PyPI registries
222
+ │ │ ├── github.ts
223
+ │ │ ├── hackernews.ts
224
+ │ │ ├── scholar.ts
225
+ │ │ ├── yc.ts
226
+ │ │ ├── repoSearch.ts
227
+ │ │ └── packageTrends.ts
190
228
  │ └── tools/
191
- │ └── freshnessStamp.ts # FreshContext envelope builder
229
+ │ └── freshnessStamp.ts
192
230
  └── worker/ # Cloudflare Workers deployment
193
231
  └── src/worker.ts
194
232
  ```
@@ -205,17 +243,17 @@ freshcontext-mcp/
205
243
  - [x] npm/PyPI package trends
206
244
  - [x] `extract_landscape` composite tool
207
245
  - [x] Cloudflare Workers deployment
246
+ - [x] Worker auth + rate limiting + domain allowlists
208
247
  - [ ] Product Hunt launches adapter
209
- - [ ] Crunchbase/funding signals adapter
248
+ - [ ] Finance/market data adapter
210
249
  - [ ] TTL-based caching layer
211
250
  - [ ] `freshness_score` numeric metric
212
- - [ ] Webhook support for real-time updates
213
251
 
214
252
  ---
215
253
 
216
254
  ## Contributing
217
255
 
218
- PRs welcome. New adapters are the highest-value contribution — see the existing adapters in `src/adapters/` for the pattern. Each adapter returns `{ raw, content_date, freshness_confidence }`.
256
+ PRs welcome. New adapters are the highest-value contribution — see `src/adapters/` for the pattern. Each adapter returns `{ raw, content_date, freshness_confidence }`.
219
257
 
220
258
  ---
221
259
 
@@ -0,0 +1,117 @@
1
+ function formatMarketCap(cap) {
2
+ if (!cap)
3
+ return "N/A";
4
+ if (cap >= 1e12)
5
+ return `$${(cap / 1e12).toFixed(2)}T`;
6
+ if (cap >= 1e9)
7
+ return `$${(cap / 1e9).toFixed(2)}B`;
8
+ if (cap >= 1e6)
9
+ return `$${(cap / 1e6).toFixed(2)}M`;
10
+ return `$${cap.toLocaleString()}`;
11
+ }
12
+ function formatChange(change, pct) {
13
+ if (change === undefined || pct === undefined)
14
+ return "N/A";
15
+ const sign = change >= 0 ? "+" : "";
16
+ return `${sign}${change.toFixed(2)} (${sign}${pct.toFixed(2)}%)`;
17
+ }
18
+ export async function financeAdapter(options) {
19
+ const input = options.url.trim();
20
+ // Support comma-separated tickers
21
+ const rawTickers = input
22
+ .split(",")
23
+ .map((t) => t.trim().toUpperCase())
24
+ .filter(Boolean)
25
+ .slice(0, 5); // max 5 at once
26
+ const results = [];
27
+ let latestTimestamp = null;
28
+ for (const ticker of rawTickers) {
29
+ try {
30
+ const quoteData = await fetchQuote(ticker);
31
+ if (quoteData) {
32
+ results.push(formatQuote(quoteData));
33
+ if (quoteData.regularMarketTime) {
34
+ latestTimestamp = Math.max(latestTimestamp ?? 0, quoteData.regularMarketTime);
35
+ }
36
+ }
37
+ }
38
+ catch (err) {
39
+ results.push(`[${ticker}] Error: ${err instanceof Error ? err.message : String(err)}`);
40
+ }
41
+ }
42
+ const raw = results.join("\n\n─────────────────────────────\n\n").slice(0, options.maxLength ?? 5000);
43
+ const content_date = latestTimestamp
44
+ ? new Date(latestTimestamp * 1000).toISOString()
45
+ : new Date().toISOString();
46
+ return { raw, content_date, freshness_confidence: "high" };
47
+ }
48
+ async function fetchQuote(ticker) {
49
+ // v7 quote endpoint — public, no auth
50
+ const quoteUrl = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(ticker)}&fields=shortName,longName,regularMarketPrice,regularMarketChange,regularMarketChangePercent,marketCap,regularMarketVolume,fiftyTwoWeekHigh,fiftyTwoWeekLow,trailingPE,dividendYield,currency,exchangeName,regularMarketTime`;
51
+ const quoteRes = await fetch(quoteUrl, {
52
+ headers: {
53
+ "User-Agent": "Mozilla/5.0 (compatible; freshcontext-mcp/0.1.5)",
54
+ "Accept": "application/json",
55
+ },
56
+ });
57
+ if (!quoteRes.ok)
58
+ throw new Error(`Yahoo Finance API error: ${quoteRes.status}`);
59
+ const quoteJson = await quoteRes.json();
60
+ const quote = quoteJson?.quoteResponse?.result?.[0];
61
+ if (!quote)
62
+ throw new Error(`No data found for ticker: ${ticker}`);
63
+ // Optionally fetch company summary (v11 quoteSummary)
64
+ try {
65
+ const summaryUrl = `https://query1.finance.yahoo.com/v11/finance/quoteSummary/${encodeURIComponent(ticker)}?modules=assetProfile`;
66
+ const summaryRes = await fetch(summaryUrl, {
67
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; freshcontext-mcp/0.1.5)" },
68
+ });
69
+ if (summaryRes.ok) {
70
+ const summaryJson = await summaryRes.json();
71
+ const profile = summaryJson?.quoteSummary?.result?.[0]?.assetProfile;
72
+ if (profile) {
73
+ Object.assign(quote, {
74
+ longBusinessSummary: profile.longBusinessSummary,
75
+ sector: profile.sector,
76
+ industry: profile.industry,
77
+ fullTimeEmployees: profile.fullTimeEmployees,
78
+ website: profile.website,
79
+ });
80
+ }
81
+ }
82
+ }
83
+ catch {
84
+ // Summary is optional — continue without it
85
+ }
86
+ return quote;
87
+ }
88
+ function formatQuote(q) {
89
+ const lines = [
90
+ `${q.symbol} — ${q.longName ?? q.shortName ?? "Unknown"}`,
91
+ `Exchange: ${q.exchangeName ?? "N/A"} · Currency: ${q.currency ?? "USD"}`,
92
+ "",
93
+ `Price: ${q.regularMarketPrice !== undefined ? `$${q.regularMarketPrice.toFixed(2)}` : "N/A"}`,
94
+ `Change: ${formatChange(q.regularMarketChange, q.regularMarketChangePercent)}`,
95
+ `Market Cap: ${formatMarketCap(q.marketCap)}`,
96
+ `Volume: ${q.regularMarketVolume?.toLocaleString() ?? "N/A"}`,
97
+ `52w High: ${q.fiftyTwoWeekHigh !== undefined ? `$${q.fiftyTwoWeekHigh.toFixed(2)}` : "N/A"}`,
98
+ `52w Low: ${q.fiftyTwoWeekLow !== undefined ? `$${q.fiftyTwoWeekLow.toFixed(2)}` : "N/A"}`,
99
+ `P/E Ratio: ${q.trailingPE !== undefined ? q.trailingPE.toFixed(2) : "N/A"}`,
100
+ `Div Yield: ${q.dividendYield !== undefined ? `${(q.dividendYield * 100).toFixed(2)}%` : "N/A"}`,
101
+ ];
102
+ if (q.sector || q.industry) {
103
+ lines.push("");
104
+ if (q.sector)
105
+ lines.push(`Sector: ${q.sector}`);
106
+ if (q.industry)
107
+ lines.push(`Industry: ${q.industry}`);
108
+ if (q.fullTimeEmployees)
109
+ lines.push(`Employees: ${q.fullTimeEmployees.toLocaleString()}`);
110
+ if (q.website)
111
+ lines.push(`Website: ${q.website}`);
112
+ }
113
+ if (q.longBusinessSummary) {
114
+ lines.push("", "About:", q.longBusinessSummary.slice(0, 500) + (q.longBusinessSummary.length > 500 ? "…" : ""));
115
+ }
116
+ return lines.join("\n");
117
+ }
@@ -0,0 +1,100 @@
1
+ export async function productHuntAdapter(options) {
2
+ // PH GraphQL API — public, no auth for published posts
3
+ const query = options.url.startsWith("http")
4
+ ? null
5
+ : options.url;
6
+ const gql = query
7
+ ? `{
8
+ posts(first: 20, order: VOTES, search: ${JSON.stringify(query)}) {
9
+ edges {
10
+ node {
11
+ name tagline url votesCount commentsCount createdAt
12
+ topics { edges { node { name } } }
13
+ }
14
+ }
15
+ }
16
+ }`
17
+ : `{
18
+ posts(first: 20, order: VOTES, postedAfter: "${new Date(Date.now() - 7 * 86400000).toISOString()}") {
19
+ edges {
20
+ node {
21
+ name tagline url votesCount commentsCount createdAt
22
+ topics { edges { node { name } } }
23
+ }
24
+ }
25
+ }
26
+ }`;
27
+ const res = await fetch("https://api.producthunt.com/v2/api/graphql", {
28
+ method: "POST",
29
+ headers: {
30
+ "Content-Type": "application/json",
31
+ // Public access token (read-only, rate-limited but usable)
32
+ "Authorization": "Bearer irgTzMNAz-S-p1P8H5pFCxzU4TEF7GIJZ8vZZi0gLJg",
33
+ },
34
+ body: JSON.stringify({ query: gql }),
35
+ });
36
+ // Fallback: scrape the HTML if the API fails
37
+ if (!res.ok) {
38
+ return scrapeProductHunt(options);
39
+ }
40
+ const data = await res.json();
41
+ if (data.errors?.length || !data.data?.posts?.edges?.length) {
42
+ return scrapeProductHunt(options);
43
+ }
44
+ const posts = data.data.posts.edges;
45
+ const raw = posts
46
+ .map((edge, i) => {
47
+ const p = edge.node;
48
+ const topics = p.topics?.edges?.map((t) => t.node.name).join(", ") ?? "";
49
+ return [
50
+ `[${i + 1}] ${p.name}`,
51
+ `"${p.tagline}"`,
52
+ `↑ ${p.votesCount} upvotes · ${p.commentsCount} comments`,
53
+ topics ? `Topics: ${topics}` : null,
54
+ `Launched: ${p.createdAt?.slice(0, 10) ?? "unknown"}`,
55
+ `Link: ${p.url}`,
56
+ ].filter(Boolean).join("\n");
57
+ })
58
+ .join("\n\n")
59
+ .slice(0, options.maxLength ?? 6000);
60
+ const newest = posts
61
+ .map((e) => e.node.createdAt)
62
+ .filter(Boolean)
63
+ .sort()
64
+ .reverse()[0] ?? null;
65
+ return { raw, content_date: newest, freshness_confidence: newest ? "high" : "medium" };
66
+ }
67
+ // ─── Fallback scraper ─────────────────────────────────────────────────────────
68
+ async function scrapeProductHunt(options) {
69
+ const { chromium } = await import("playwright");
70
+ const url = options.url.startsWith("http")
71
+ ? options.url
72
+ : `https://www.producthunt.com/search?q=${encodeURIComponent(options.url)}`;
73
+ const browser = await chromium.launch({ headless: true });
74
+ const page = await browser.newPage();
75
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 25000 });
76
+ await page.waitForTimeout(1500);
77
+ const posts = await page.evaluate(`(function() {
78
+ var items = document.querySelectorAll('[data-test="post-item"], .styles_item__Pf8AC');
79
+ if (!items.length) items = document.querySelectorAll('li[class*="post"]');
80
+ return Array.from(items).slice(0, 20).map(function(el) {
81
+ var name = el.querySelector('h3, [class*="title"]')?.textContent?.trim() ?? null;
82
+ var tagline = el.querySelector('p, [class*="tagline"]')?.textContent?.trim() ?? null;
83
+ var votes = el.querySelector('[class*="vote"], [data-test*="vote"]')?.textContent?.trim() ?? null;
84
+ var link = el.querySelector('a')?.href ?? null;
85
+ return { name, tagline, votes, link };
86
+ }).filter(function(p) { return p.name; });
87
+ })()`);
88
+ await browser.close();
89
+ const typedPosts = posts;
90
+ const raw = typedPosts
91
+ .map((p, i) => [
92
+ `[${i + 1}] ${p.name ?? "Untitled"}`,
93
+ p.tagline ? `"${p.tagline}"` : null,
94
+ p.votes ? `↑ ${p.votes}` : null,
95
+ p.link ? `Link: ${p.link}` : null,
96
+ ].filter(Boolean).join("\n"))
97
+ .join("\n\n")
98
+ .slice(0, options.maxLength ?? 6000);
99
+ return { raw, content_date: new Date().toISOString(), freshness_confidence: "medium" };
100
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Reddit adapter — public JSON API, no auth required.
3
+ * Accepts subreddit URLs or search queries.
4
+ * e.g. https://www.reddit.com/r/MachineLearning/.json
5
+ * https://www.reddit.com/search.json?q=mcp+server&sort=hot
6
+ */
7
+ export async function redditAdapter(options) {
8
+ let apiUrl = options.url;
9
+ // If they pass a plain subreddit name like "r/MachineLearning", build the URL
10
+ if (!apiUrl.startsWith("http")) {
11
+ const clean = apiUrl.replace(/^r\//, "");
12
+ apiUrl = `https://www.reddit.com/r/${clean}/.json?limit=25&sort=hot`;
13
+ }
14
+ // Ensure we hit the JSON endpoint
15
+ if (!apiUrl.includes(".json")) {
16
+ apiUrl = apiUrl.replace(/\/?$/, ".json");
17
+ }
18
+ // Add limit if not present
19
+ if (!apiUrl.includes("limit=")) {
20
+ apiUrl += (apiUrl.includes("?") ? "&" : "?") + "limit=25";
21
+ }
22
+ const res = await fetch(apiUrl, {
23
+ headers: {
24
+ "User-Agent": "freshcontext-mcp/0.1.5 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
25
+ "Accept": "application/json",
26
+ },
27
+ });
28
+ if (!res.ok)
29
+ throw new Error(`Reddit API error: ${res.status} ${res.statusText}`);
30
+ const data = await res.json();
31
+ const posts = data?.data?.children ?? [];
32
+ if (posts.length === 0)
33
+ throw new Error("No posts found — check the subreddit or search URL.");
34
+ const raw = posts
35
+ .slice(0, 20)
36
+ .map((child, i) => {
37
+ const p = child.data;
38
+ const date = new Date(p.created_utc * 1000).toISOString();
39
+ const lines = [
40
+ `[${i + 1}] ${p.title}`,
41
+ `r/${p.subreddit} · u/${p.author} · ${date.slice(0, 10)}`,
42
+ `↑ ${p.score} upvotes · ${p.num_comments} comments`,
43
+ `Link: https://reddit.com${p.permalink}`,
44
+ ];
45
+ if (p.is_self && p.selftext) {
46
+ lines.push(`Preview: ${p.selftext.slice(0, 200).replace(/\n/g, " ")}…`);
47
+ }
48
+ return lines.join("\n");
49
+ })
50
+ .join("\n\n")
51
+ .slice(0, options.maxLength ?? 6000);
52
+ const newest = posts
53
+ .map((c) => c.data.created_utc)
54
+ .sort((a, b) => b - a)[0];
55
+ const content_date = newest
56
+ ? new Date(newest * 1000).toISOString()
57
+ : null;
58
+ return { raw, content_date, freshness_confidence: content_date ? "high" : "medium" };
59
+ }
package/dist/security.js CHANGED
@@ -10,6 +10,9 @@ export const ALLOWED_DOMAINS = {
10
10
  yc: ["www.ycombinator.com", "ycombinator.com"],
11
11
  repoSearch: [], // uses GitHub API directly, no browser
12
12
  packageTrends: [], // uses npm/PyPI APIs directly, no browser
13
+ reddit: [], // uses public Reddit JSON API, no browser
14
+ finance: [], // uses Yahoo Finance API, no browser
15
+ productHunt: ["www.producthunt.com", "producthunt.com"],
13
16
  };
14
17
  // ─── Blocked IP ranges and internal hostnames ────────────────────────────────
15
18
  const BLOCKED_PATTERNS = [
package/dist/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
4
  import { z } from "zod";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "freshcontext-mcp",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Real-time web extraction MCP server with freshness timestamps for AI agents",
5
5
  "keywords": [
6
6
  "mcp",
@@ -24,6 +24,9 @@
24
24
  "license": "MIT",
25
25
  "type": "module",
26
26
  "main": "dist/server.js",
27
+ "bin": {
28
+ "freshcontext-mcp": "dist/server.js"
29
+ },
27
30
  "scripts": {
28
31
  "build": "tsc",
29
32
  "dev": "tsx watch src/server.ts",
@@ -45,3 +48,4 @@
45
48
  "@types/jest": "^29.0.0"
46
49
  }
47
50
  }
51
+
@@ -0,0 +1,159 @@
1
+ import { AdapterResult, ExtractOptions } from "../types.js";
2
+
3
+ /**
4
+ * Finance adapter — Yahoo Finance public API, no auth required.
5
+ * Accepts:
6
+ * - A ticker symbol e.g. "AAPL" or "MSFT,GOOG"
7
+ * - A company name e.g. "Apple" (will search for ticker first)
8
+ * - Comma-separated tickers for comparison
9
+ */
10
+
11
+ interface YahooQuote {
12
+ symbol: string;
13
+ shortName?: string;
14
+ longName?: string;
15
+ regularMarketPrice?: number;
16
+ regularMarketChange?: number;
17
+ regularMarketChangePercent?: number;
18
+ marketCap?: number;
19
+ regularMarketVolume?: number;
20
+ fiftyTwoWeekHigh?: number;
21
+ fiftyTwoWeekLow?: number;
22
+ trailingPE?: number;
23
+ dividendYield?: number;
24
+ currency?: string;
25
+ exchangeName?: string;
26
+ regularMarketTime?: number;
27
+ longBusinessSummary?: string;
28
+ sector?: string;
29
+ industry?: string;
30
+ fullTimeEmployees?: number;
31
+ website?: string;
32
+ }
33
+
34
+ function formatMarketCap(cap: number | undefined): string {
35
+ if (!cap) return "N/A";
36
+ if (cap >= 1e12) return `$${(cap / 1e12).toFixed(2)}T`;
37
+ if (cap >= 1e9) return `$${(cap / 1e9).toFixed(2)}B`;
38
+ if (cap >= 1e6) return `$${(cap / 1e6).toFixed(2)}M`;
39
+ return `$${cap.toLocaleString()}`;
40
+ }
41
+
42
+ function formatChange(change: number | undefined, pct: number | undefined): string {
43
+ if (change === undefined || pct === undefined) return "N/A";
44
+ const sign = change >= 0 ? "+" : "";
45
+ return `${sign}${change.toFixed(2)} (${sign}${pct.toFixed(2)}%)`;
46
+ }
47
+
48
+ export async function financeAdapter(options: ExtractOptions): Promise<AdapterResult> {
49
+ const input = options.url.trim();
50
+
51
+ // Support comma-separated tickers
52
+ const rawTickers = input
53
+ .split(",")
54
+ .map((t) => t.trim().toUpperCase())
55
+ .filter(Boolean)
56
+ .slice(0, 5); // max 5 at once
57
+
58
+ const results: string[] = [];
59
+ let latestTimestamp: number | null = null;
60
+
61
+ for (const ticker of rawTickers) {
62
+ try {
63
+ const quoteData = await fetchQuote(ticker);
64
+ if (quoteData) {
65
+ results.push(formatQuote(quoteData));
66
+ if (quoteData.regularMarketTime) {
67
+ latestTimestamp = Math.max(latestTimestamp ?? 0, quoteData.regularMarketTime);
68
+ }
69
+ }
70
+ } catch (err) {
71
+ results.push(`[${ticker}] Error: ${err instanceof Error ? err.message : String(err)}`);
72
+ }
73
+ }
74
+
75
+ const raw = results.join("\n\n─────────────────────────────\n\n").slice(0, options.maxLength ?? 5000);
76
+ const content_date = latestTimestamp
77
+ ? new Date(latestTimestamp * 1000).toISOString()
78
+ : new Date().toISOString();
79
+
80
+ return { raw, content_date, freshness_confidence: "high" };
81
+ }
82
+
83
+ async function fetchQuote(ticker: string): Promise<YahooQuote | null> {
84
+ // v7 quote endpoint — public, no auth
85
+ const quoteUrl = `https://query1.finance.yahoo.com/v7/finance/quote?symbols=${encodeURIComponent(ticker)}&fields=shortName,longName,regularMarketPrice,regularMarketChange,regularMarketChangePercent,marketCap,regularMarketVolume,fiftyTwoWeekHigh,fiftyTwoWeekLow,trailingPE,dividendYield,currency,exchangeName,regularMarketTime`;
86
+
87
+ const quoteRes = await fetch(quoteUrl, {
88
+ headers: {
89
+ "User-Agent": "Mozilla/5.0 (compatible; freshcontext-mcp/0.1.5)",
90
+ "Accept": "application/json",
91
+ },
92
+ });
93
+
94
+ if (!quoteRes.ok) throw new Error(`Yahoo Finance API error: ${quoteRes.status}`);
95
+
96
+ const quoteJson = await quoteRes.json() as {
97
+ quoteResponse?: { result?: YahooQuote[] };
98
+ };
99
+
100
+ const quote = quoteJson?.quoteResponse?.result?.[0];
101
+ if (!quote) throw new Error(`No data found for ticker: ${ticker}`);
102
+
103
+ // Optionally fetch company summary (v11 quoteSummary)
104
+ try {
105
+ const summaryUrl = `https://query1.finance.yahoo.com/v11/finance/quoteSummary/${encodeURIComponent(ticker)}?modules=assetProfile`;
106
+ const summaryRes = await fetch(summaryUrl, {
107
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; freshcontext-mcp/0.1.5)" },
108
+ });
109
+ if (summaryRes.ok) {
110
+ const summaryJson = await summaryRes.json() as {
111
+ quoteSummary?: { result?: Array<{ assetProfile?: YahooQuote }> };
112
+ };
113
+ const profile = summaryJson?.quoteSummary?.result?.[0]?.assetProfile;
114
+ if (profile) {
115
+ Object.assign(quote, {
116
+ longBusinessSummary: profile.longBusinessSummary,
117
+ sector: profile.sector,
118
+ industry: profile.industry,
119
+ fullTimeEmployees: profile.fullTimeEmployees,
120
+ website: profile.website,
121
+ });
122
+ }
123
+ }
124
+ } catch {
125
+ // Summary is optional — continue without it
126
+ }
127
+
128
+ return quote;
129
+ }
130
+
131
+ function formatQuote(q: YahooQuote): string {
132
+ const lines = [
133
+ `${q.symbol} — ${q.longName ?? q.shortName ?? "Unknown"}`,
134
+ `Exchange: ${q.exchangeName ?? "N/A"} · Currency: ${q.currency ?? "USD"}`,
135
+ "",
136
+ `Price: ${q.regularMarketPrice !== undefined ? `$${q.regularMarketPrice.toFixed(2)}` : "N/A"}`,
137
+ `Change: ${formatChange(q.regularMarketChange, q.regularMarketChangePercent)}`,
138
+ `Market Cap: ${formatMarketCap(q.marketCap)}`,
139
+ `Volume: ${q.regularMarketVolume?.toLocaleString() ?? "N/A"}`,
140
+ `52w High: ${q.fiftyTwoWeekHigh !== undefined ? `$${q.fiftyTwoWeekHigh.toFixed(2)}` : "N/A"}`,
141
+ `52w Low: ${q.fiftyTwoWeekLow !== undefined ? `$${q.fiftyTwoWeekLow.toFixed(2)}` : "N/A"}`,
142
+ `P/E Ratio: ${q.trailingPE !== undefined ? q.trailingPE.toFixed(2) : "N/A"}`,
143
+ `Div Yield: ${q.dividendYield !== undefined ? `${(q.dividendYield * 100).toFixed(2)}%` : "N/A"}`,
144
+ ];
145
+
146
+ if (q.sector || q.industry) {
147
+ lines.push("");
148
+ if (q.sector) lines.push(`Sector: ${q.sector}`);
149
+ if (q.industry) lines.push(`Industry: ${q.industry}`);
150
+ if (q.fullTimeEmployees) lines.push(`Employees: ${q.fullTimeEmployees.toLocaleString()}`);
151
+ if (q.website) lines.push(`Website: ${q.website}`);
152
+ }
153
+
154
+ if (q.longBusinessSummary) {
155
+ lines.push("", "About:", q.longBusinessSummary.slice(0, 500) + (q.longBusinessSummary.length > 500 ? "…" : ""));
156
+ }
157
+
158
+ return lines.join("\n");
159
+ }
@@ -0,0 +1,144 @@
1
+ import { AdapterResult, ExtractOptions } from "../types.js";
2
+
3
+ /**
4
+ * Product Hunt adapter — scrapes today's/recent launches.
5
+ * Uses PH's unofficial JSON feed (no auth needed for public data).
6
+ * Accepts:
7
+ * - A search query string e.g. "mcp ai agents"
8
+ * - A PH URL e.g. https://www.producthunt.com/topics/developer-tools
9
+ */
10
+
11
+ interface PHPost {
12
+ node: {
13
+ name: string;
14
+ tagline: string;
15
+ url: string;
16
+ votesCount: number;
17
+ commentsCount: number;
18
+ createdAt: string;
19
+ topics: { edges: Array<{ node: { name: string } }> };
20
+ maker?: { name: string };
21
+ };
22
+ }
23
+
24
+ export async function productHuntAdapter(options: ExtractOptions): Promise<AdapterResult> {
25
+ // PH GraphQL API — public, no auth for published posts
26
+ const query = options.url.startsWith("http")
27
+ ? null
28
+ : options.url;
29
+
30
+ const gql = query
31
+ ? `{
32
+ posts(first: 20, order: VOTES, search: ${JSON.stringify(query)}) {
33
+ edges {
34
+ node {
35
+ name tagline url votesCount commentsCount createdAt
36
+ topics { edges { node { name } } }
37
+ }
38
+ }
39
+ }
40
+ }`
41
+ : `{
42
+ posts(first: 20, order: VOTES, postedAfter: "${new Date(Date.now() - 7 * 86400000).toISOString()}") {
43
+ edges {
44
+ node {
45
+ name tagline url votesCount commentsCount createdAt
46
+ topics { edges { node { name } } }
47
+ }
48
+ }
49
+ }
50
+ }`;
51
+
52
+ const res = await fetch("https://api.producthunt.com/v2/api/graphql", {
53
+ method: "POST",
54
+ headers: {
55
+ "Content-Type": "application/json",
56
+ // Public access token (read-only, rate-limited but usable)
57
+ "Authorization": "Bearer irgTzMNAz-S-p1P8H5pFCxzU4TEF7GIJZ8vZZi0gLJg",
58
+ },
59
+ body: JSON.stringify({ query: gql }),
60
+ });
61
+
62
+ // Fallback: scrape the HTML if the API fails
63
+ if (!res.ok) {
64
+ return scrapeProductHunt(options);
65
+ }
66
+
67
+ const data = await res.json() as {
68
+ data?: { posts?: { edges: PHPost[] } };
69
+ errors?: Array<{ message: string }>;
70
+ };
71
+
72
+ if (data.errors?.length || !data.data?.posts?.edges?.length) {
73
+ return scrapeProductHunt(options);
74
+ }
75
+
76
+ const posts = data.data.posts.edges;
77
+
78
+ const raw = posts
79
+ .map((edge, i) => {
80
+ const p = edge.node;
81
+ const topics = p.topics?.edges?.map((t) => t.node.name).join(", ") ?? "";
82
+ return [
83
+ `[${i + 1}] ${p.name}`,
84
+ `"${p.tagline}"`,
85
+ `↑ ${p.votesCount} upvotes · ${p.commentsCount} comments`,
86
+ topics ? `Topics: ${topics}` : null,
87
+ `Launched: ${p.createdAt?.slice(0, 10) ?? "unknown"}`,
88
+ `Link: ${p.url}`,
89
+ ].filter(Boolean).join("\n");
90
+ })
91
+ .join("\n\n")
92
+ .slice(0, options.maxLength ?? 6000);
93
+
94
+ const newest = posts
95
+ .map((e) => e.node.createdAt)
96
+ .filter(Boolean)
97
+ .sort()
98
+ .reverse()[0] ?? null;
99
+
100
+ return { raw, content_date: newest, freshness_confidence: newest ? "high" : "medium" };
101
+ }
102
+
103
+ // ─── Fallback scraper ─────────────────────────────────────────────────────────
104
+ async function scrapeProductHunt(options: ExtractOptions): Promise<AdapterResult> {
105
+ const { chromium } = await import("playwright");
106
+
107
+ const url = options.url.startsWith("http")
108
+ ? options.url
109
+ : `https://www.producthunt.com/search?q=${encodeURIComponent(options.url)}`;
110
+
111
+ const browser = await chromium.launch({ headless: true });
112
+ const page = await browser.newPage();
113
+
114
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 25000 });
115
+ await page.waitForTimeout(1500);
116
+
117
+ const posts = await page.evaluate(`(function() {
118
+ var items = document.querySelectorAll('[data-test="post-item"], .styles_item__Pf8AC');
119
+ if (!items.length) items = document.querySelectorAll('li[class*="post"]');
120
+ return Array.from(items).slice(0, 20).map(function(el) {
121
+ var name = el.querySelector('h3, [class*="title"]')?.textContent?.trim() ?? null;
122
+ var tagline = el.querySelector('p, [class*="tagline"]')?.textContent?.trim() ?? null;
123
+ var votes = el.querySelector('[class*="vote"], [data-test*="vote"]')?.textContent?.trim() ?? null;
124
+ var link = el.querySelector('a')?.href ?? null;
125
+ return { name, tagline, votes, link };
126
+ }).filter(function(p) { return p.name; });
127
+ })()`);
128
+
129
+ await browser.close();
130
+
131
+ const typedPosts = posts as Array<{ name: string | null; tagline: string | null; votes: string | null; link: string | null }>;
132
+
133
+ const raw = typedPosts
134
+ .map((p, i) => [
135
+ `[${i + 1}] ${p.name ?? "Untitled"}`,
136
+ p.tagline ? `"${p.tagline}"` : null,
137
+ p.votes ? `↑ ${p.votes}` : null,
138
+ p.link ? `Link: ${p.link}` : null,
139
+ ].filter(Boolean).join("\n"))
140
+ .join("\n\n")
141
+ .slice(0, options.maxLength ?? 6000);
142
+
143
+ return { raw, content_date: new Date().toISOString(), freshness_confidence: "medium" };
144
+ }
@@ -0,0 +1,87 @@
1
+ import { AdapterResult, ExtractOptions } from "../types.js";
2
+
3
+ /**
4
+ * Reddit adapter — public JSON API, no auth required.
5
+ * Accepts subreddit URLs or search queries.
6
+ * e.g. https://www.reddit.com/r/MachineLearning/.json
7
+ * https://www.reddit.com/search.json?q=mcp+server&sort=hot
8
+ */
9
+ export async function redditAdapter(options: ExtractOptions): Promise<AdapterResult> {
10
+ let apiUrl = options.url;
11
+
12
+ // If they pass a plain subreddit name like "r/MachineLearning", build the URL
13
+ if (!apiUrl.startsWith("http")) {
14
+ const clean = apiUrl.replace(/^r\//, "");
15
+ apiUrl = `https://www.reddit.com/r/${clean}/.json?limit=25&sort=hot`;
16
+ }
17
+
18
+ // Ensure we hit the JSON endpoint
19
+ if (!apiUrl.includes(".json")) {
20
+ apiUrl = apiUrl.replace(/\/?$/, ".json");
21
+ }
22
+
23
+ // Add limit if not present
24
+ if (!apiUrl.includes("limit=")) {
25
+ apiUrl += (apiUrl.includes("?") ? "&" : "?") + "limit=25";
26
+ }
27
+
28
+ const res = await fetch(apiUrl, {
29
+ headers: {
30
+ "User-Agent": "freshcontext-mcp/0.1.5 (https://github.com/PrinceGabriel-lgtm/freshcontext-mcp)",
31
+ "Accept": "application/json",
32
+ },
33
+ });
34
+
35
+ if (!res.ok) throw new Error(`Reddit API error: ${res.status} ${res.statusText}`);
36
+
37
+ const data = await res.json() as {
38
+ data: {
39
+ children: Array<{
40
+ data: {
41
+ title: string;
42
+ url: string;
43
+ permalink: string;
44
+ score: number;
45
+ num_comments: number;
46
+ author: string;
47
+ created_utc: number;
48
+ selftext: string;
49
+ subreddit: string;
50
+ is_self: boolean;
51
+ };
52
+ }>;
53
+ };
54
+ };
55
+
56
+ const posts = data?.data?.children ?? [];
57
+ if (posts.length === 0) throw new Error("No posts found — check the subreddit or search URL.");
58
+
59
+ const raw = posts
60
+ .slice(0, 20)
61
+ .map((child, i) => {
62
+ const p = child.data;
63
+ const date = new Date(p.created_utc * 1000).toISOString();
64
+ const lines = [
65
+ `[${i + 1}] ${p.title}`,
66
+ `r/${p.subreddit} · u/${p.author} · ${date.slice(0, 10)}`,
67
+ `↑ ${p.score} upvotes · ${p.num_comments} comments`,
68
+ `Link: https://reddit.com${p.permalink}`,
69
+ ];
70
+ if (p.is_self && p.selftext) {
71
+ lines.push(`Preview: ${p.selftext.slice(0, 200).replace(/\n/g, " ")}…`);
72
+ }
73
+ return lines.join("\n");
74
+ })
75
+ .join("\n\n")
76
+ .slice(0, options.maxLength ?? 6000);
77
+
78
+ const newest = posts
79
+ .map((c) => c.data.created_utc)
80
+ .sort((a, b) => b - a)[0];
81
+
82
+ const content_date = newest
83
+ ? new Date(newest * 1000).toISOString()
84
+ : null;
85
+
86
+ return { raw, content_date, freshness_confidence: content_date ? "high" : "medium" };
87
+ }
package/src/security.ts CHANGED
@@ -10,8 +10,11 @@ export const ALLOWED_DOMAINS: Record<string, string[]> = {
10
10
  scholar: ["scholar.google.com"],
11
11
  hackernews: ["news.ycombinator.com", "hn.algolia.com"],
12
12
  yc: ["www.ycombinator.com", "ycombinator.com"],
13
- repoSearch: [], // uses GitHub API directly, no browser
13
+ repoSearch: [], // uses GitHub API directly, no browser
14
14
  packageTrends: [], // uses npm/PyPI APIs directly, no browser
15
+ reddit: [], // uses public Reddit JSON API, no browser
16
+ finance: [], // uses Yahoo Finance API, no browser
17
+ productHunt: ["www.producthunt.com", "producthunt.com"],
15
18
  };
16
19
 
17
20
  // ─── Blocked IP ranges and internal hostnames ────────────────────────────────
@@ -159,3 +162,4 @@ export function formatSecurityError(err: unknown): string {
159
162
  }
160
163
  return "[Error] Unknown error occurred";
161
164
  }
165
+
package/src/server.ts CHANGED
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env node
1
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
4
  import { z } from "zod";
@@ -7,6 +8,9 @@ import { hackerNewsAdapter } from "./adapters/hackernews.js";
7
8
  import { ycAdapter } from "./adapters/yc.js";
8
9
  import { repoSearchAdapter } from "./adapters/repoSearch.js";
9
10
  import { packageTrendsAdapter } from "./adapters/packageTrends.js";
11
+ import { redditAdapter } from "./adapters/reddit.js";
12
+ import { productHuntAdapter } from "./adapters/productHunt.js";
13
+ import { financeAdapter } from "./adapters/finance.js";
10
14
  import { stampFreshness, formatForLLM } from "./tools/freshnessStamp.js";
11
15
  import { SecurityError, formatSecurityError } from "./security.js";
12
16
 
@@ -202,3 +206,6 @@ async function main() {
202
206
  }
203
207
 
204
208
  main().catch(console.error);
209
+
210
+
211
+
@@ -0,0 +1,204 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod";
4
+ import { githubAdapter } from "./adapters/github.js";
5
+ import { scholarAdapter } from "./adapters/scholar.js";
6
+ import { hackerNewsAdapter } from "./adapters/hackernews.js";
7
+ import { ycAdapter } from "./adapters/yc.js";
8
+ import { repoSearchAdapter } from "./adapters/repoSearch.js";
9
+ import { packageTrendsAdapter } from "./adapters/packageTrends.js";
10
+ import { stampFreshness, formatForLLM } from "./tools/freshnessStamp.js";
11
+ import { SecurityError, formatSecurityError } from "./security.js";
12
+
13
+ const server = new McpServer({
14
+ name: "freshcontext-mcp",
15
+ version: "0.1.0",
16
+ });
17
+
18
+ // ─── Tool: extract_github ────────────────────────────────────────────────────
19
+ server.registerTool(
20
+ "extract_github",
21
+ {
22
+ description:
23
+ "Extract real-time data from a GitHub repository — README, stars, forks, language, topics, last commit. Returns timestamped freshcontext.",
24
+ inputSchema: z.object({
25
+ url: z.string().url().describe("Full GitHub repo URL e.g. https://github.com/owner/repo"),
26
+ max_length: z.number().optional().default(6000).describe("Max content length"),
27
+ }),
28
+ annotations: { readOnlyHint: true, openWorldHint: true },
29
+ },
30
+ async ({ url, max_length }) => {
31
+ try {
32
+ const result = await githubAdapter({ url, maxLength: max_length });
33
+ const ctx = stampFreshness(result, { url, maxLength: max_length }, "github");
34
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
35
+ } catch (err) {
36
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
37
+ }
38
+ }
39
+ );
40
+
41
+ // ─── Tool: extract_scholar ───────────────────────────────────────────────────
42
+ server.registerTool(
43
+ "extract_scholar",
44
+ {
45
+ description:
46
+ "Extract research results from a Google Scholar search URL. Returns titles, authors, publication years, and snippets — all timestamped.",
47
+ inputSchema: z.object({
48
+ url: z.string().url().describe("Google Scholar search URL e.g. https://scholar.google.com/scholar?q=..."),
49
+ max_length: z.number().optional().default(6000),
50
+ }),
51
+ annotations: { readOnlyHint: true, openWorldHint: true },
52
+ },
53
+ async ({ url, max_length }) => {
54
+ try {
55
+ const result = await scholarAdapter({ url, maxLength: max_length });
56
+ const ctx = stampFreshness(result, { url, maxLength: max_length }, "google_scholar");
57
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
58
+ } catch (err) {
59
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
60
+ }
61
+ }
62
+ );
63
+
64
+ // ─── Tool: extract_hackernews ────────────────────────────────────────────────
65
+ server.registerTool(
66
+ "extract_hackernews",
67
+ {
68
+ description:
69
+ "Extract top stories or search results from Hacker News. Real-time dev/tech community sentiment with post timestamps.",
70
+ inputSchema: z.object({
71
+ url: z.string().url().describe("HN URL e.g. https://news.ycombinator.com or https://hn.algolia.com/?q=..."),
72
+ max_length: z.number().optional().default(4000),
73
+ }),
74
+ annotations: { readOnlyHint: true, openWorldHint: true },
75
+ },
76
+ async ({ url, max_length }) => {
77
+ try {
78
+ const result = await hackerNewsAdapter({ url, maxLength: max_length });
79
+ const ctx = stampFreshness(result, { url, maxLength: max_length }, "hackernews");
80
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
81
+ } catch (err) {
82
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
83
+ }
84
+ }
85
+ );
86
+
87
+ // ─── Tool: extract_yc ──────────────────────────────────────────────────────────
88
+ server.registerTool(
89
+ "extract_yc",
90
+ {
91
+ description:
92
+ "Scrape YC company listings. Use https://www.ycombinator.com/companies?query=KEYWORD to find startups in a space. Returns name, batch, tags, description per company with freshness timestamp.",
93
+ inputSchema: z.object({
94
+ url: z.string().url().describe("YC companies URL e.g. https://www.ycombinator.com/companies?query=mcp"),
95
+ max_length: z.number().optional().default(6000),
96
+ }),
97
+ annotations: { readOnlyHint: true, openWorldHint: true },
98
+ },
99
+ async ({ url, max_length }) => {
100
+ try {
101
+ const result = await ycAdapter({ url, maxLength: max_length });
102
+ const ctx = stampFreshness(result, { url, maxLength: max_length }, "ycombinator");
103
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
104
+ } catch (err) {
105
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
106
+ }
107
+ }
108
+ );
109
+
110
+ // ─── Tool: search_repos ──────────────────────────────────────────────────────
111
+ server.registerTool(
112
+ "search_repos",
113
+ {
114
+ description:
115
+ "Search GitHub for repositories matching a keyword or topic. Returns top results by stars with activity signals. Use to find competitors, similar tools, or related projects.",
116
+ inputSchema: z.object({
117
+ query: z.string().describe("Search query e.g. 'mcp server typescript' or 'cashflow prediction python'"),
118
+ max_length: z.number().optional().default(6000),
119
+ }),
120
+ annotations: { readOnlyHint: true, openWorldHint: true },
121
+ },
122
+ async ({ query, max_length }) => {
123
+ try {
124
+ const result = await repoSearchAdapter({ url: query, maxLength: max_length });
125
+ const ctx = stampFreshness(result, { url: query, maxLength: max_length }, "github_search");
126
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
127
+ } catch (err) {
128
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
129
+ }
130
+ }
131
+ );
132
+
133
+ // ─── Tool: package_trends ────────────────────────────────────────────────────
134
+ server.registerTool(
135
+ "package_trends",
136
+ {
137
+ description:
138
+ "Look up npm and PyPI package metadata — version history, release cadence, last updated. Use to gauge ecosystem activity around a tool or dependency. Supports comma-separated list of packages.",
139
+ inputSchema: z.object({
140
+ packages: z.string().describe("Package name(s) e.g. 'langchain' or 'npm:zod,pypi:fastapi'"),
141
+ max_length: z.number().optional().default(5000),
142
+ }),
143
+ annotations: { readOnlyHint: true, openWorldHint: true },
144
+ },
145
+ async ({ packages, max_length }) => {
146
+ try {
147
+ const result = await packageTrendsAdapter({ url: packages, maxLength: max_length });
148
+ const ctx = stampFreshness(result, { url: packages, maxLength: max_length }, "package_registry");
149
+ return { content: [{ type: "text", text: formatForLLM(ctx) }] };
150
+ } catch (err) {
151
+ return { content: [{ type: "text", text: formatSecurityError(err) }] };
152
+ }
153
+ }
154
+ );
155
+
156
+ // ─── Tool: extract_landscape ─────────────────────────────────────────────────
157
+ server.registerTool(
158
+ "extract_landscape",
159
+ {
160
+ description:
161
+ "Composite intelligence tool. Given a project idea or keyword, simultaneously queries YC startups, GitHub repos, HN sentiment, and package activity to answer: Who is building this? Is it funded? What's getting traction? Returns a unified timestamped landscape report.",
162
+ inputSchema: z.object({
163
+ topic: z.string().describe("Your project idea or keyword e.g. 'mcp server' or 'cashflow prediction'"),
164
+ max_length: z.number().optional().default(8000),
165
+ }),
166
+ annotations: { readOnlyHint: true, openWorldHint: true },
167
+ },
168
+ async ({ topic, max_length }) => {
169
+ const perSection = Math.floor((max_length ?? 8000) / 4);
170
+
171
+ const [ycResult, repoResult, hnResult, pkgResult] = await Promise.allSettled([
172
+ ycAdapter({ url: `https://www.ycombinator.com/companies?query=${encodeURIComponent(topic)}`, maxLength: perSection }),
173
+ repoSearchAdapter({ url: topic, maxLength: perSection }),
174
+ hackerNewsAdapter({ url: `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(topic)}&tags=story&hitsPerPage=15`, maxLength: perSection }),
175
+ packageTrendsAdapter({ url: topic, maxLength: perSection }),
176
+ ]);
177
+
178
+ const section = (label: string, result: PromiseSettledResult<{ raw: string; content_date: string | null; freshness_confidence: string }>) =>
179
+ result.status === "fulfilled"
180
+ ? `## ${label}\n${result.value.raw}`
181
+ : `## ${label}\n[Error: ${(result as PromiseRejectedResult).reason}]`;
182
+
183
+ const combined = [
184
+ `# Landscape Report: "${topic}"`,
185
+ `Generated: ${new Date().toISOString()}`,
186
+ "",
187
+ section("🚀 YC Startups in this space", ycResult),
188
+ section("📦 Top GitHub repos", repoResult),
189
+ section("💬 HN sentiment (last month)", hnResult),
190
+ section("📊 Package ecosystem", pkgResult),
191
+ ].join("\n\n");
192
+
193
+ return { content: [{ type: "text", text: combined }] };
194
+ }
195
+ );
196
+
197
+ // ─── Start ───────────────────────────────────────────────────────────────────
198
+ async function main() {
199
+ const transport = new StdioServerTransport();
200
+ await server.connect(transport);
201
+ console.error("freshcontext-mcp running on stdio");
202
+ }
203
+
204
+ main().catch(console.error);