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 +71 -33
- package/dist/adapters/finance.js +117 -0
- package/dist/adapters/productHunt.js +100 -0
- package/dist/adapters/reddit.js +59 -0
- package/dist/security.js +3 -0
- package/dist/server.js +1 -0
- package/package.json +5 -1
- package/src/adapters/finance.ts +159 -0
- package/src/adapters/productHunt.ts +144 -0
- package/src/adapters/reddit.ts +87 -0
- package/src/security.ts +5 -1
- package/src/server.ts +7 -0
- package/src/server.ts.bak +204 -0
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-
|
|
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.
|
|
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
|
-
###
|
|
63
|
+
### Option A — Cloud (no install, works immediately)
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
105
|
+
"freshcontext": {
|
|
90
106
|
"command": "node",
|
|
91
|
-
"args": ["/
|
|
107
|
+
"args": ["/Users/YOUR_USERNAME/path/to/freshcontext-mcp/dist/server.js"]
|
|
92
108
|
}
|
|
93
109
|
}
|
|
94
110
|
}
|
|
95
111
|
```
|
|
96
112
|
|
|
97
|
-
|
|
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
|
|
105
|
-
"command": "
|
|
106
|
-
"args": ["-
|
|
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
|
|
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
|
|
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
|
|
185
|
-
│ │ ├── hackernews.ts
|
|
186
|
-
│ │ ├── scholar.ts
|
|
187
|
-
│ │ ├── yc.ts
|
|
188
|
-
│ │ ├── repoSearch.ts
|
|
189
|
-
│ │ └── packageTrends.ts
|
|
222
|
+
│ │ ├── github.ts
|
|
223
|
+
│ │ ├── hackernews.ts
|
|
224
|
+
│ │ ├── scholar.ts
|
|
225
|
+
│ │ ├── yc.ts
|
|
226
|
+
│ │ ├── repoSearch.ts
|
|
227
|
+
│ │ └── packageTrends.ts
|
|
190
228
|
│ └── tools/
|
|
191
|
-
│ └── freshnessStamp.ts
|
|
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
|
-
- [ ]
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "freshcontext-mcp",
|
|
3
|
-
"version": "0.1.
|
|
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: [],
|
|
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);
|