freshcontext-mcp 0.1.8 β 0.2.0
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 +212 -257
- package/dist/adapters/jobs.js +243 -0
- package/dist/server.js +30 -0
- package/package.json +51 -51
package/README.md
CHANGED
|
@@ -1,257 +1,212 @@
|
|
|
1
|
-
# freshcontext-mcp
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
|
40
|
-
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
|
49
|
-
|
|
50
|
-
| `
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
**
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
-
|
|
197
|
-
-
|
|
198
|
-
-
|
|
199
|
-
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
β β βββ hackernews.ts
|
|
214
|
-
β β βββ scholar.ts
|
|
215
|
-
β β βββ yc.ts
|
|
216
|
-
β β βββ repoSearch.ts
|
|
217
|
-
β β βββ packageTrends.ts
|
|
218
|
-
β β βββ reddit.ts
|
|
219
|
-
β β βββ productHunt.ts
|
|
220
|
-
β β βββ finance.ts
|
|
221
|
-
β βββ tools/
|
|
222
|
-
β βββ freshnessStamp.ts
|
|
223
|
-
βββ worker/ # Cloudflare Workers deployment (all 10 tools)
|
|
224
|
-
βββ src/worker.ts
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
---
|
|
228
|
-
|
|
229
|
-
## Roadmap
|
|
230
|
-
|
|
231
|
-
- [x] GitHub adapter
|
|
232
|
-
- [x] Hacker News adapter
|
|
233
|
-
- [x] Google Scholar adapter
|
|
234
|
-
- [x] YC startup scraper
|
|
235
|
-
- [x] GitHub repo search
|
|
236
|
-
- [x] npm/PyPI package trends
|
|
237
|
-
- [x] `extract_landscape` composite tool
|
|
238
|
-
- [x] Cloudflare Workers deployment
|
|
239
|
-
- [x] Worker auth + KV-backed global rate limiting
|
|
240
|
-
- [x] Reddit community sentiment adapter
|
|
241
|
-
- [x] Product Hunt launches adapter
|
|
242
|
-
- [x] Yahoo Finance market data adapter
|
|
243
|
-
- [ ] `extract_arxiv` β structured arXiv API (more reliable than Scholar)
|
|
244
|
-
- [ ] TTL-based caching layer
|
|
245
|
-
- [ ] `freshness_score` numeric metric
|
|
246
|
-
|
|
247
|
-
---
|
|
248
|
-
|
|
249
|
-
## Contributing
|
|
250
|
-
|
|
251
|
-
PRs welcome. New adapters are the highest-value contribution β see `src/adapters/` for the pattern. Each adapter returns `{ raw, content_date, freshness_confidence }`.
|
|
252
|
-
|
|
253
|
-
---
|
|
254
|
-
|
|
255
|
-
## License
|
|
256
|
-
|
|
257
|
-
MIT
|
|
1
|
+
# freshcontext-mcp
|
|
2
|
+
|
|
3
|
+
I asked Claude to help me find a job. It gave me a list of openings. I applied to three of them. Two didn't exist anymore. One had been closed for two years.
|
|
4
|
+
|
|
5
|
+
Claude had no idea. It presented everything with the same confidence.
|
|
6
|
+
|
|
7
|
+
That's the problem freshcontext fixes.
|
|
8
|
+
|
|
9
|
+
[](https://www.npmjs.com/package/freshcontext-mcp)
|
|
10
|
+
[](https://opensource.org/licenses/MIT)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
Every MCP server returns data. freshcontext returns data **plus when it was retrieved and how confident that date is** β wrapped in a FreshContext envelope:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
[FRESHCONTEXT]
|
|
20
|
+
Source: https://github.com/owner/repo
|
|
21
|
+
Published: 2024-11-03
|
|
22
|
+
Retrieved: 2026-03-05T09:19:00Z
|
|
23
|
+
Confidence: high
|
|
24
|
+
---
|
|
25
|
+
... content ...
|
|
26
|
+
[/FRESHCONTEXT]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Claude now knows the difference between something from this morning and something from two years ago. You do too.
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 11 tools. No API keys.
|
|
34
|
+
|
|
35
|
+
### Intelligence
|
|
36
|
+
| Tool | What it gets you |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `extract_github` | README, stars, forks, language, topics, last commit |
|
|
39
|
+
| `extract_hackernews` | Top stories or search results with scores and timestamps |
|
|
40
|
+
| `extract_scholar` | Research papers β titles, authors, years, snippets |
|
|
41
|
+
| `extract_arxiv` | arXiv papers via official API β more reliable than Scholar |
|
|
42
|
+
| `extract_reddit` | Posts and community sentiment from any subreddit |
|
|
43
|
+
|
|
44
|
+
### Competitive research
|
|
45
|
+
| Tool | What it gets you |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `extract_yc` | YC company listings by keyword β who's funded in your space |
|
|
48
|
+
| `extract_producthunt` | Recent launches by topic |
|
|
49
|
+
| `search_repos` | GitHub repos ranked by stars with activity signals |
|
|
50
|
+
| `package_trends` | npm and PyPI metadata β version history, release cadence |
|
|
51
|
+
|
|
52
|
+
### Market data
|
|
53
|
+
| Tool | What it gets you |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `extract_finance` | Live stock data β price, market cap, P/E, 52w range |
|
|
56
|
+
|
|
57
|
+
### Composite
|
|
58
|
+
| Tool | What it gets you |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `extract_landscape` | One call. YC + GitHub + HN + Reddit + Product Hunt + npm in parallel. Full timestamped picture. |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Quick Start
|
|
65
|
+
|
|
66
|
+
### Option A β Cloud (no install)
|
|
67
|
+
|
|
68
|
+
Add to your Claude Desktop config and restart:
|
|
69
|
+
|
|
70
|
+
**Mac:** `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
71
|
+
**Windows:** `%APPDATA%\Claude\claude_desktop_config.json`
|
|
72
|
+
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"mcpServers": {
|
|
76
|
+
"freshcontext": {
|
|
77
|
+
"command": "npx",
|
|
78
|
+
"args": ["-y", "mcp-remote", "https://freshcontext-mcp.gimmanuel73.workers.dev/mcp"]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Restart Claude. Done.
|
|
85
|
+
|
|
86
|
+
> Prefer a guided setup? Visit **[freshcontext-site.pages.dev](https://freshcontext-site.pages.dev)** β 3 steps, no terminal.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
### Option B β Local (full Playwright)
|
|
91
|
+
|
|
92
|
+
**Requires:** Node.js 18+ ([nodejs.org](https://nodejs.org))
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git clone https://github.com/PrinceGabriel-lgtm/freshcontext-mcp
|
|
96
|
+
cd freshcontext-mcp
|
|
97
|
+
npm install
|
|
98
|
+
npx playwright install chromium
|
|
99
|
+
npm run build
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Add to Claude Desktop config:
|
|
103
|
+
|
|
104
|
+
**Mac:**
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"mcpServers": {
|
|
108
|
+
"freshcontext": {
|
|
109
|
+
"command": "node",
|
|
110
|
+
"args": ["/Users/YOUR_USERNAME/path/to/freshcontext-mcp/dist/server.js"]
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Windows:**
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"mcpServers": {
|
|
120
|
+
"freshcontext": {
|
|
121
|
+
"command": "node",
|
|
122
|
+
"args": ["C:\\Users\\YOUR_USERNAME\\path\\to\\freshcontext-mcp\\dist\\server.js"]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
### Troubleshooting (Mac)
|
|
131
|
+
|
|
132
|
+
**"command not found: node"** β Use the full path:
|
|
133
|
+
```bash
|
|
134
|
+
which node # copy this output, replace "node" in config
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
**Config file doesn't exist** β Create it:
|
|
138
|
+
```bash
|
|
139
|
+
mkdir -p ~/Library/Application\ Support/Claude
|
|
140
|
+
touch ~/Library/Application\ Support/Claude/claude_desktop_config.json
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Usage examples
|
|
146
|
+
|
|
147
|
+
**Is anyone already building what you're building?**
|
|
148
|
+
```
|
|
149
|
+
Use extract_landscape with topic "cashflow prediction saas"
|
|
150
|
+
```
|
|
151
|
+
Returns who's funded, what's trending, what repos exist, what packages are moving β all timestamped.
|
|
152
|
+
|
|
153
|
+
**What's the community actually saying right now?**
|
|
154
|
+
```
|
|
155
|
+
Use extract_reddit on r/MachineLearning
|
|
156
|
+
Use extract_hackernews to search "mcp server 2026"
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
**Did that company actually ship recently?**
|
|
160
|
+
```
|
|
161
|
+
Use extract_github on https://github.com/some-org/some-repo
|
|
162
|
+
```
|
|
163
|
+
Check `Published` vs `Retrieved`. If the gap is 18 months, Claude will tell you.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## How freshness works
|
|
168
|
+
|
|
169
|
+
Most AI tools retrieve data silently. No timestamp, no signal, no way for the agent to know how old it is.
|
|
170
|
+
|
|
171
|
+
freshcontext treats **retrieval time as first-class metadata**. Every adapter returns:
|
|
172
|
+
|
|
173
|
+
- `retrieved_at` β exact ISO timestamp of the fetch
|
|
174
|
+
- `content_date` β best estimate of when the content was originally published
|
|
175
|
+
- `freshness_confidence` β `high`, `medium`, or `low` based on signal quality
|
|
176
|
+
- `adapter` β which source the data came from
|
|
177
|
+
|
|
178
|
+
When confidence is `high`, the date came from a structured field (API, metadata). When it's `medium` or `low`, freshcontext tells you why.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Security
|
|
183
|
+
|
|
184
|
+
- Input sanitization and domain allowlists on all adapters
|
|
185
|
+
- SSRF prevention (blocked private IP ranges)
|
|
186
|
+
- KV-backed global rate limiting: 60 req/min per IP across all edge nodes
|
|
187
|
+
- No credentials required β all public data sources
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Roadmap
|
|
192
|
+
|
|
193
|
+
- [x] GitHub, HN, Scholar, YC, Reddit, Product Hunt, Finance, arXiv adapters
|
|
194
|
+
- [x] `extract_landscape` β 6-source composite tool
|
|
195
|
+
- [x] Cloudflare Workers deployment
|
|
196
|
+
- [x] KV-backed global rate limiting
|
|
197
|
+
- [x] Listed on official MCP Registry
|
|
198
|
+
- [ ] TTL-based caching layer
|
|
199
|
+
- [ ] `freshness_score` numeric metric
|
|
200
|
+
- [ ] Job search adapter (the tool that started all this)
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Contributing
|
|
205
|
+
|
|
206
|
+
PRs welcome. New adapters are the highest-value contribution β see `src/adapters/` for the pattern.
|
|
207
|
+
|
|
208
|
+
---
|
|
209
|
+
|
|
210
|
+
## License
|
|
211
|
+
|
|
212
|
+
MIT
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jobs adapter v2 β 4 sources, freshness badges, location + keyword filtering.
|
|
3
|
+
*
|
|
4
|
+
* Sources (all no-auth):
|
|
5
|
+
* - Remotive β remote jobs, location filterable
|
|
6
|
+
* - RemoteOK β pure remote, salary data, unix timestamps
|
|
7
|
+
* - The Muse β structured jobs, location + category
|
|
8
|
+
* - HN Who is Hiring β community-sourced, raw but real
|
|
9
|
+
*
|
|
10
|
+
* Every listing gets a freshness badge:
|
|
11
|
+
* π’ < 7 days β FRESH, apply now
|
|
12
|
+
* π‘ 7β30 days β still good
|
|
13
|
+
* π΄ 31β90 days β apply with caution
|
|
14
|
+
* β > 90 days β likely expired, shown last
|
|
15
|
+
*
|
|
16
|
+
* This adapter was the whole reason freshcontext exists.
|
|
17
|
+
*/
|
|
18
|
+
// βββ Freshness Scoring ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
19
|
+
function freshnessBadge(dateStr) {
|
|
20
|
+
if (!dateStr)
|
|
21
|
+
return { badge: "β Unknown date", days: 9999 };
|
|
22
|
+
const days = Math.floor((Date.now() - new Date(dateStr).getTime()) / 86400000);
|
|
23
|
+
if (days < 0)
|
|
24
|
+
return { badge: "π’ Just posted β FRESH", days: 0 };
|
|
25
|
+
if (days <= 7)
|
|
26
|
+
return { badge: `π’ ${days}d ago β FRESH`, days };
|
|
27
|
+
if (days <= 30)
|
|
28
|
+
return { badge: `π‘ ${days}d ago`, days };
|
|
29
|
+
if (days <= 90)
|
|
30
|
+
return { badge: `π΄ ${days}d ago β apply with caution`, days };
|
|
31
|
+
return { badge: `β ${days}d ago β likely expired`, days };
|
|
32
|
+
}
|
|
33
|
+
function matchesLocation(locationField, filterLocation) {
|
|
34
|
+
if (!filterLocation || filterLocation.toLowerCase() === "worldwide" || filterLocation.toLowerCase() === "remote")
|
|
35
|
+
return true;
|
|
36
|
+
const loc = locationField.toLowerCase();
|
|
37
|
+
const filter = filterLocation.toLowerCase();
|
|
38
|
+
return loc.includes(filter) || filter.includes(loc) || loc.includes("worldwide") || loc.includes("anywhere") || loc.includes("remote");
|
|
39
|
+
}
|
|
40
|
+
function highlightKeywords(text, keywords) {
|
|
41
|
+
if (!keywords.length)
|
|
42
|
+
return text;
|
|
43
|
+
let result = text;
|
|
44
|
+
for (const kw of keywords) {
|
|
45
|
+
const re = new RegExp(`(${kw})`, "gi");
|
|
46
|
+
result = result.replace(re, "β‘$1");
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
// βββ Main Adapter βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
51
|
+
export async function jobsAdapter(options) {
|
|
52
|
+
const query = options.url.trim();
|
|
53
|
+
const maxLength = options.maxLength ?? 8000;
|
|
54
|
+
const location = options.location ?? "";
|
|
55
|
+
const remoteOnly = options.remoteOnly ?? false;
|
|
56
|
+
const maxAgeDays = options.maxAgeDays ?? 60;
|
|
57
|
+
const keywords = options.keywords ?? [];
|
|
58
|
+
const perSource = Math.floor(maxLength / 4);
|
|
59
|
+
const [remotiveRes, remoteOkRes, museRes, hnRes] = await Promise.allSettled([
|
|
60
|
+
fetchRemotive(query, location, maxAgeDays, keywords, perSource),
|
|
61
|
+
fetchRemoteOK(query, location, maxAgeDays, keywords, perSource),
|
|
62
|
+
remoteOnly ? Promise.reject("skipped (remote_only mode)") : fetchMuse(query, location, maxAgeDays, keywords, perSource),
|
|
63
|
+
fetchHNHiring(query, location, maxAgeDays, keywords, perSource),
|
|
64
|
+
]);
|
|
65
|
+
const pool = [];
|
|
66
|
+
const harvest = (res, label) => {
|
|
67
|
+
if (res.status === "fulfilled")
|
|
68
|
+
pool.push(...res.value.listings);
|
|
69
|
+
// silently skip rejected sources β don't clutter output
|
|
70
|
+
};
|
|
71
|
+
harvest(remotiveRes, "Remotive");
|
|
72
|
+
harvest(remoteOkRes, "RemoteOK");
|
|
73
|
+
harvest(museRes, "The Muse");
|
|
74
|
+
harvest(hnRes, "HN");
|
|
75
|
+
if (!pool.length) {
|
|
76
|
+
return {
|
|
77
|
+
raw: `No job listings found for "${query}"${location ? ` in ${location}` : ""}.\n\nTips:\nβ’ Try broader terms e.g. "engineer" instead of "senior TypeScript engineer"\nβ’ Set location to "remote" for worldwide results\nβ’ Increase max_age_days`,
|
|
78
|
+
content_date: null,
|
|
79
|
+
freshness_confidence: "low",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// Sort: freshest first, expired listings last
|
|
83
|
+
pool.sort((a, b) => a.days - b.days);
|
|
84
|
+
const freshCount = pool.filter(l => l.days <= 7).length;
|
|
85
|
+
const goodCount = pool.filter(l => l.days > 7 && l.days <= 30).length;
|
|
86
|
+
const staleCount = pool.filter(l => l.days > 30).length;
|
|
87
|
+
const header = [
|
|
88
|
+
`# Job Search: "${query}"${location ? ` Β· ${location}` : ""}${remoteOnly ? " Β· remote only" : ""}`,
|
|
89
|
+
`Retrieved: ${new Date().toISOString()}`,
|
|
90
|
+
`Found: ${pool.length} listings β π’ ${freshCount} fresh π‘ ${goodCount} recent π΄ ${staleCount} older`,
|
|
91
|
+
`β οΈ Listings sorted freshest first. Check the date badge before you apply.`,
|
|
92
|
+
keywords.length ? `π Watching for: ${keywords.map(k => `β‘${k}`).join(", ")}` : null,
|
|
93
|
+
"",
|
|
94
|
+
].filter(Boolean).join("\n");
|
|
95
|
+
const body = pool
|
|
96
|
+
.map(l => l.text)
|
|
97
|
+
.join("\n\nβββββββββββββββββββββββββββββ\n\n");
|
|
98
|
+
const raw = (header + "\n\n" + body).slice(0, maxLength);
|
|
99
|
+
const freshestDays = pool[0]?.days ?? 9999;
|
|
100
|
+
const newestDate = freshestDays < 9999
|
|
101
|
+
? new Date(Date.now() - freshestDays * 86400000).toISOString().slice(0, 10)
|
|
102
|
+
: null;
|
|
103
|
+
return {
|
|
104
|
+
raw,
|
|
105
|
+
content_date: newestDate,
|
|
106
|
+
freshness_confidence: freshestDays <= 7 ? "high" : freshestDays <= 30 ? "medium" : "low",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// βββ Source: Remotive βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
110
|
+
async function fetchRemotive(query, location, maxAgeDays, keywords, maxLength) {
|
|
111
|
+
const url = `https://remotive.com/api/remote-jobs?search=${encodeURIComponent(query)}&limit=15`;
|
|
112
|
+
const res = await fetch(url, {
|
|
113
|
+
headers: { "User-Agent": "freshcontext-mcp/0.2.0", "Accept": "application/json" },
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok)
|
|
116
|
+
throw new Error(`Remotive ${res.status}`);
|
|
117
|
+
const data = await res.json();
|
|
118
|
+
const listings = (data.jobs ?? [])
|
|
119
|
+
.filter(j => matchesLocation(j.candidate_required_location ?? "", location))
|
|
120
|
+
.map(j => {
|
|
121
|
+
const { badge, days } = freshnessBadge(j.publication_date);
|
|
122
|
+
if (days > maxAgeDays)
|
|
123
|
+
return null;
|
|
124
|
+
const text = highlightKeywords([
|
|
125
|
+
`[Remotive] ${j.title} β ${j.company_name}`,
|
|
126
|
+
`${badge}`,
|
|
127
|
+
`Location: ${j.candidate_required_location || "Remote"} | Type: ${j.job_type || "N/A"}`,
|
|
128
|
+
j.salary ? `Salary: ${j.salary}` : null,
|
|
129
|
+
j.tags?.length ? `Tags: ${j.tags.slice(0, 6).join(", ")}` : null,
|
|
130
|
+
`Apply: ${j.url}`,
|
|
131
|
+
].filter(Boolean).join("\n"), keywords);
|
|
132
|
+
return { text, days, source: "remotive" };
|
|
133
|
+
})
|
|
134
|
+
.filter((l) => l !== null)
|
|
135
|
+
.slice(0, 8);
|
|
136
|
+
return { listings };
|
|
137
|
+
}
|
|
138
|
+
// βββ Source: RemoteOK βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
139
|
+
async function fetchRemoteOK(query, location, maxAgeDays, keywords, maxLength) {
|
|
140
|
+
const tag = query.toLowerCase().replace(/\s+/g, "-");
|
|
141
|
+
const url = `https://remoteok.com/api?tag=${encodeURIComponent(tag)}`;
|
|
142
|
+
const res = await fetch(url, {
|
|
143
|
+
headers: { "User-Agent": "freshcontext-mcp/0.2.0", "Accept": "application/json" },
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok)
|
|
146
|
+
throw new Error(`RemoteOK ${res.status}`);
|
|
147
|
+
const raw = await res.json();
|
|
148
|
+
// First element is a legal notice object, skip it
|
|
149
|
+
const jobs = raw.filter(j => j.id && j.position);
|
|
150
|
+
const listings = jobs
|
|
151
|
+
.filter(j => matchesLocation(j.location ?? "Remote", location))
|
|
152
|
+
.map(j => {
|
|
153
|
+
const dateStr = j.date ?? (j.epoch ? new Date(j.epoch * 1000).toISOString() : null);
|
|
154
|
+
const { badge, days } = freshnessDate(dateStr);
|
|
155
|
+
if (days > maxAgeDays)
|
|
156
|
+
return null;
|
|
157
|
+
const salary = j.salary_min && j.salary_max
|
|
158
|
+
? `$${(j.salary_min / 1000).toFixed(0)}kβ$${(j.salary_max / 1000).toFixed(0)}k`
|
|
159
|
+
: null;
|
|
160
|
+
const text = highlightKeywords([
|
|
161
|
+
`[RemoteOK] ${j.position} β ${j.company ?? "Unknown"}`,
|
|
162
|
+
`${badge}`,
|
|
163
|
+
`Location: ${j.location || "Remote Worldwide"}`,
|
|
164
|
+
salary ? `Salary: ${salary}` : null,
|
|
165
|
+
j.tags?.length ? `Tags: ${j.tags.slice(0, 6).join(", ")}` : null,
|
|
166
|
+
j.url ? `Apply: ${j.url}` : null,
|
|
167
|
+
].filter(Boolean).join("\n"), keywords);
|
|
168
|
+
return { text, days, source: "remoteok" };
|
|
169
|
+
})
|
|
170
|
+
.filter((l) => l !== null)
|
|
171
|
+
.slice(0, 8);
|
|
172
|
+
return { listings };
|
|
173
|
+
}
|
|
174
|
+
// βββ Source: The Muse βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
175
|
+
async function fetchMuse(query, location, maxAgeDays, keywords, maxLength) {
|
|
176
|
+
const locParam = location && location.toLowerCase() !== "remote"
|
|
177
|
+
? `&location=${encodeURIComponent(location)}`
|
|
178
|
+
: "&location=Flexible%20%2F%20Remote";
|
|
179
|
+
const url = `https://www.themuse.com/api/public/jobs?category=${encodeURIComponent(query)}${locParam}&page=0&descending=true`;
|
|
180
|
+
const res = await fetch(url, {
|
|
181
|
+
headers: { "User-Agent": "freshcontext-mcp/0.2.0", "Accept": "application/json" },
|
|
182
|
+
});
|
|
183
|
+
if (!res.ok)
|
|
184
|
+
throw new Error(`The Muse ${res.status}`);
|
|
185
|
+
const data = await res.json();
|
|
186
|
+
const listings = (data.results ?? [])
|
|
187
|
+
.map(j => {
|
|
188
|
+
const locationStr = j.locations?.map(l => l.name).join(", ") || "Flexible/Remote";
|
|
189
|
+
const { badge, days } = freshnessDate(j.publication_date);
|
|
190
|
+
if (days > maxAgeDays)
|
|
191
|
+
return null;
|
|
192
|
+
const level = j.levels?.map(l => l.name).join(", ") || null;
|
|
193
|
+
const text = highlightKeywords([
|
|
194
|
+
`[The Muse] ${j.name} β ${j.company?.name ?? "Unknown"}`,
|
|
195
|
+
`${badge}`,
|
|
196
|
+
`Location: ${locationStr}`,
|
|
197
|
+
level ? `Level: ${level}` : null,
|
|
198
|
+
`Apply: ${j.refs?.landing_page ?? "N/A"}`,
|
|
199
|
+
].filter(Boolean).join("\n"), keywords);
|
|
200
|
+
return { text, days, source: "themuse" };
|
|
201
|
+
})
|
|
202
|
+
.filter((l) => l !== null)
|
|
203
|
+
.slice(0, 8);
|
|
204
|
+
return { listings };
|
|
205
|
+
}
|
|
206
|
+
// βββ Source: HN Who is Hiring βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
207
|
+
async function fetchHNHiring(query, location, maxAgeDays, keywords, maxLength) {
|
|
208
|
+
const searchQ = [query, location, "hiring"].filter(Boolean).join(" ");
|
|
209
|
+
const url = `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(searchQ)}&tags=comment&hitsPerPage=10`;
|
|
210
|
+
const res = await fetch(url, { headers: { "User-Agent": "freshcontext-mcp/0.2.0" } });
|
|
211
|
+
if (!res.ok)
|
|
212
|
+
throw new Error(`HN ${res.status}`);
|
|
213
|
+
const data = await res.json();
|
|
214
|
+
const listings = (data.hits ?? [])
|
|
215
|
+
.filter(h => {
|
|
216
|
+
const t = (h.comment_text ?? "").toLowerCase();
|
|
217
|
+
return t.includes("hiring") || t.includes("remote") || t.includes("full-time") || t.includes("apply");
|
|
218
|
+
})
|
|
219
|
+
.map(h => {
|
|
220
|
+
const { badge, days } = freshnessDate(h.created_at);
|
|
221
|
+
if (days > maxAgeDays)
|
|
222
|
+
return null;
|
|
223
|
+
const excerpt = (h.comment_text ?? "")
|
|
224
|
+
.replace(/<[^>]+>/g, " ")
|
|
225
|
+
.replace(/\s+/g, " ")
|
|
226
|
+
.trim()
|
|
227
|
+
.slice(0, 350);
|
|
228
|
+
const text = highlightKeywords([
|
|
229
|
+
`[HN Hiring] Posted by ${h.author}`,
|
|
230
|
+
`${badge}`,
|
|
231
|
+
excerpt + (excerpt.length >= 350 ? "β¦" : ""),
|
|
232
|
+
`Source: https://news.ycombinator.com/item?id=${h.objectID}`,
|
|
233
|
+
].join("\n"), keywords);
|
|
234
|
+
return { text, days, source: "hn" };
|
|
235
|
+
})
|
|
236
|
+
.filter((l) => l !== null)
|
|
237
|
+
.slice(0, 6);
|
|
238
|
+
return { listings };
|
|
239
|
+
}
|
|
240
|
+
// βββ Shared date helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
241
|
+
function freshnessDate(dateStr) {
|
|
242
|
+
return freshnessBadge(dateStr ?? null);
|
|
243
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -8,6 +8,7 @@ import { hackerNewsAdapter } from "./adapters/hackernews.js";
|
|
|
8
8
|
import { ycAdapter } from "./adapters/yc.js";
|
|
9
9
|
import { repoSearchAdapter } from "./adapters/repoSearch.js";
|
|
10
10
|
import { packageTrendsAdapter } from "./adapters/packageTrends.js";
|
|
11
|
+
import { jobsAdapter } from "./adapters/jobs.js";
|
|
11
12
|
import { stampFreshness, formatForLLM } from "./tools/freshnessStamp.js";
|
|
12
13
|
import { formatSecurityError } from "./security.js";
|
|
13
14
|
const server = new McpServer({
|
|
@@ -152,6 +153,35 @@ server.registerTool("extract_landscape", {
|
|
|
152
153
|
].join("\n\n");
|
|
153
154
|
return { content: [{ type: "text", text: combined }] };
|
|
154
155
|
});
|
|
156
|
+
// βββ Tool: search_jobs βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
157
|
+
server.registerTool("search_jobs", {
|
|
158
|
+
description: "Search for real-time job listings with freshness badges on every result β so you never apply to a role that closed months ago. Sources: Remotive + RemoteOK + The Muse + HN 'Who is Hiring'. Supports location filtering, remote-only mode, keyword spotting (e.g. FIFO), and max age filtering. Returns timestamped freshcontext sorted freshest first.",
|
|
159
|
+
inputSchema: z.object({
|
|
160
|
+
query: z.string().describe("Job search query e.g. 'typescript', 'mining engineer', 'FIFO operator', 'data analyst'"),
|
|
161
|
+
location: z.string().optional().describe("Country, city, or 'remote' / 'worldwide' e.g. 'South Africa', 'Australia', 'remote'"),
|
|
162
|
+
remote_only: z.boolean().optional().default(false).describe("Only return remote-friendly listings"),
|
|
163
|
+
max_age_days: z.number().optional().default(60).describe("Hide listings older than N days (default 60, use 7 for very fresh only)"),
|
|
164
|
+
keywords: z.array(z.string()).optional().default([]).describe("Keywords to highlight in results e.g. ['FIFO', 'underground', 'contract']"),
|
|
165
|
+
max_length: z.number().optional().default(8000),
|
|
166
|
+
}),
|
|
167
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
168
|
+
}, async ({ query, location, remote_only, max_age_days, keywords, max_length }) => {
|
|
169
|
+
try {
|
|
170
|
+
const result = await jobsAdapter({
|
|
171
|
+
url: query,
|
|
172
|
+
maxLength: max_length,
|
|
173
|
+
location: location ?? "",
|
|
174
|
+
remoteOnly: remote_only,
|
|
175
|
+
maxAgeDays: max_age_days,
|
|
176
|
+
keywords: keywords ?? [],
|
|
177
|
+
});
|
|
178
|
+
const ctx = stampFreshness(result, { url: query, maxLength: max_length }, "jobs");
|
|
179
|
+
return { content: [{ type: "text", text: formatForLLM(ctx) }] };
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return { content: [{ type: "text", text: formatSecurityError(err) }] };
|
|
183
|
+
}
|
|
184
|
+
});
|
|
155
185
|
// βββ Start βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
156
186
|
async function main() {
|
|
157
187
|
const transport = new StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,54 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "freshcontext-mcp",
|
|
3
|
-
"mcpName": "io.github.PrinceGabriel-lgtm/freshcontext",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Real-time web extraction MCP server with freshness timestamps for AI agents",
|
|
6
|
-
"keywords": [
|
|
7
|
-
"mcp",
|
|
8
|
-
"mcp-server",
|
|
9
|
-
"ai-agents",
|
|
10
|
-
"llm",
|
|
11
|
-
"freshness",
|
|
12
|
-
"web-scraping",
|
|
13
|
-
"github-analytics",
|
|
14
|
-
"hackernews",
|
|
15
|
-
"yc",
|
|
16
|
-
"typescript",
|
|
17
|
-
"context",
|
|
18
|
-
"model-context-protocol"
|
|
19
|
-
],
|
|
20
|
-
"homepage": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp",
|
|
21
|
-
"repository": {
|
|
22
|
-
"type": "git",
|
|
23
|
-
"url": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp.git"
|
|
24
|
-
},
|
|
25
|
-
"license": "MIT",
|
|
26
|
-
"type": "module",
|
|
27
|
-
"main": "dist/server.js",
|
|
28
|
-
"bin": {
|
|
29
|
-
"freshcontext-mcp": "dist/server.js"
|
|
30
|
-
},
|
|
31
|
-
"scripts": {
|
|
32
|
-
"build": "tsc",
|
|
33
|
-
"dev": "tsx watch src/server.ts",
|
|
34
|
-
"start": "node dist/server.js",
|
|
35
|
-
"inspect": "npx @modelcontextprotocol/inspector tsx src/server.ts",
|
|
36
|
-
"test": "jest"
|
|
37
|
-
},
|
|
38
|
-
"dependencies": {
|
|
39
|
-
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
40
|
-
"playwright": "^1.44.0",
|
|
41
|
-
"zod": "^3.23.0",
|
|
42
|
-
"dotenv": "^16.4.0"
|
|
43
|
-
},
|
|
44
|
-
"devDependencies": {
|
|
45
|
-
"@types/node": "^20.0.0",
|
|
46
|
-
"tsx": "^4.0.0",
|
|
47
|
-
"typescript": "^5.4.0",
|
|
48
|
-
"jest": "^29.0.0",
|
|
49
|
-
"@types/jest": "^29.0.0"
|
|
50
|
-
}
|
|
51
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "freshcontext-mcp",
|
|
3
|
+
"mcpName": "io.github.PrinceGabriel-lgtm/freshcontext",
|
|
4
|
+
"version": "0.2.0",
|
|
5
|
+
"description": "Real-time web extraction MCP server with freshness timestamps for AI agents",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"mcp",
|
|
8
|
+
"mcp-server",
|
|
9
|
+
"ai-agents",
|
|
10
|
+
"llm",
|
|
11
|
+
"freshness",
|
|
12
|
+
"web-scraping",
|
|
13
|
+
"github-analytics",
|
|
14
|
+
"hackernews",
|
|
15
|
+
"yc",
|
|
16
|
+
"typescript",
|
|
17
|
+
"context",
|
|
18
|
+
"model-context-protocol"
|
|
19
|
+
],
|
|
20
|
+
"homepage": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/PrinceGabriel-lgtm/freshcontext-mcp.git"
|
|
24
|
+
},
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"type": "module",
|
|
27
|
+
"main": "dist/server.js",
|
|
28
|
+
"bin": {
|
|
29
|
+
"freshcontext-mcp": "dist/server.js"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"dev": "tsx watch src/server.ts",
|
|
34
|
+
"start": "node dist/server.js",
|
|
35
|
+
"inspect": "npx @modelcontextprotocol/inspector tsx src/server.ts",
|
|
36
|
+
"test": "jest"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
40
|
+
"playwright": "^1.44.0",
|
|
41
|
+
"zod": "^3.23.0",
|
|
42
|
+
"dotenv": "^16.4.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^20.0.0",
|
|
46
|
+
"tsx": "^4.0.0",
|
|
47
|
+
"typescript": "^5.4.0",
|
|
48
|
+
"jest": "^29.0.0",
|
|
49
|
+
"@types/jest": "^29.0.0"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
52
|
|
|
53
53
|
|
|
54
54
|
|