Whoishiring MCP Server
by nickw409
README.md
# HN "Who's Hiring" Job Scanner
An automated job discovery and tracking pipeline that scrapes multiple sources, applies a multi-stage filter cascade, and exposes a 24-tool MCP server so Claude Desktop can analyze job fit against a resume — all without requiring an API key.
## What It Does
Aggregates job postings from two sources (Hacker News "Who is hiring?" threads via Algolia API, and YC's Work at a Startup board via Playwright headless browser), runs them through a shared multi-stage filter pipeline, maintains a bounded tracking system with backlog promotion, and provides a full MCP (Model Context Protocol) tool interface for an LLM to analyze, rank, and manage a job search pipeline end-to-end.
## Architecture
```
Algolia API ──→ hn_jobs.py ──┐
├──→ filters.py ──→ mcp_server.py ──→ Claude Desktop
Playwright ──→ waas.py ──┘ │ │
Shared filter 24 MCP tools
pipeline 8 JSON stores
Description cache
```
### Multi-Source Ingestion
- **HN threads**: Algolia search API → parallelized comment fetch (20-worker ThreadPoolExecutor) → HTML comment parsing to extract company, location, remote status, emails, job board URLs
- **WAAS**: Playwright headless browser with authenticated session → infinite-scroll scraping → structured field extraction (title, salary, batch, company size, seniority)
- Both sources feed into the same `filters.py` pipeline — keyword scoring, negative filters, seniority estimation, job type classification, and location detection share a single implementation
### Filter Pipeline (`filters.py`)
Five-stage filter cascade, each with configurable behavior:
1. **Weighted keyword scoring** — Three categories scored by relevance (AI tooling: 3, Systems: 2, General AI+SWE: 1). Scoring is per-category, not per-keyword — multiple hits in one category don't stack. All matching uses compiled `\b` word-boundary regex, case-insensitive.
2. **Negative keyword filter** — Detects senior/management titles (staff, principal, director, VP) and high experience thresholds (10+, 15+ years). Matched posts aren't silently dropped — they go to a "Filtered Out" section so nothing is lost.
3. **Seniority estimation** — Infers seniority from job title keywords and description experience-year requirements. Maps to a level scale (intern → junior → mid → senior → staff+). Configurable max level — jobs above the threshold are filtered. Unknown seniority is never filtered (benefit of the doubt).
4. **Job type classification** — Classifies roles as coding (engineer, developer, SRE, etc.) vs non-coding (PM, designer, sales, recruiter). Engineering management is classified as non-coding. Unknown types are kept.
5. **Location filter** — Regex matching against known US and non-US cities/countries. Non-US, non-remote jobs are filtered. No detected location = kept (benefit of the doubt).
### Bounded Job Tracking System
Eight JSON files managed atomically (read-modify-write) by the MCP server:
```
scan_waas ──→ [all new jobs] ──→ backlog_jobs.json (overflow, ranked by score)
│
▼ (top N promoted)
tracked_jobs.json (max N active, default 20)
│
┌──────────────┼──────────────┐
▼ ▼ ▼
applied_jobs dismissed_jobs longshot_jobs
(permanent) (validated) (validated)
│
┌─────┴─────┐
▼ ▼
rejected_jobs accepted_jobs
(outcome) (outcome)
```
- **Tracked** — Bounded active pipeline (max N, configurable). Only the highest-scoring jobs from backlog fill these slots. Every `mark_applied`, `mark_dismissed`, or `mark_longshot` call frees a slot and auto-promotes the top backlog entry, returning the promoted job in the response to avoid redundant `get_tracked_jobs` calls.
- **Backlog** — Unbounded overflow, sorted by keyword score. Jobs that pass all filters but don't make the top N cut. Promotion happens automatically when tracked slots open.
- **Applied** — Permanent record. Not validated against WAAS (job might be filled but the application record matters).
- **Dismissed / Longshot** — Validated periodically against WAAS — dead listings are pruned automatically.
- **Rejected / Accepted** — Outcome tracking for applied jobs. `mark_rejected` and `mark_accepted` move jobs from applied to their respective stores. Both can be reverted to tracked via `mark_open`.
### Description Caching
Full job descriptions are cached to disk during scanning (`job_descriptions.json`). `get_job_details` reads from cache first, falls back to live HTTP + BeautifulSoup parsing. Cache is auto-pruned — descriptions are removed when jobs leave all active stores (tracked, backlog, applied, longshot, rejected, accepted). Applied/rejected/accepted jobs retain their descriptions permanently.
### MCP Server (24 Tools)
Stdio-transport MCP server for Claude Desktop. Claude Desktop acts as the LLM ranker, eliminating the need for an API key. The server owns all state — Claude never writes to JSON files directly, only through tool calls.
**Scanning**: `scan_jobs`, `scan_waas`, `scan_all`, `get_job_details`
- `scan_waas` returns only run metadata (counts, timing, active filters) — not job data. Job data flows through the tracking system.
- `scan_all` combines HN + WAAS with cross-source dedup by company name (case-insensitive, whitespace-stripped). HN takes priority.
- `get_job_details` serves from disk cache, falls back to live fetch with structured extraction (JSON-LD → Open Graph → meta tags → title tag).
**Tracking**: `get_tracked_jobs`, `get_applied_jobs`, `get_dismissed_jobs`, `get_longshot_jobs`, `get_rejected_jobs`, `get_accepted_jobs`, `update_job_analysis`, `mark_applied`, `mark_dismissed`, `mark_longshot`, `mark_rejected`, `mark_accepted`, `mark_open`, `swap_role`, `validate_tracked_jobs`, `reset_tracking`
- `swap_role` replaces a tracked job with an alternate role URL from the same company (e.g., a better-fit position discovered through `other_roles`), clearing stale analysis.
- `validate_tracked_jobs` checks open/dismissed/longshot jobs against WAAS, removes dead listings, and backfills from backlog.
- `mark_applied`/`mark_dismissed`/`mark_longshot` free a tracked slot and return the newly promoted backlog job inline, enabling a dismiss-and-analyze loop without re-fetching the full tracked list.
- `mark_rejected`/`mark_accepted` move applied jobs to outcome tracking. Both can be reverted via `mark_open`.
**Config**: `get_resume`, `get_preferences`, `get_config`, `update_config`, `get_latest_results`
- `get_resume` extracts text from a configured PDF via PyMuPDF.
- `update_config` writes to `config.yaml` for runtime filter/preference changes.
### Daily Workflow (Automated via MCP Prompt)
1. `validate_tracked_jobs` — prune dead listings from tracked/dismissed/longshot, backfill from backlog
2. `scan_waas` — discover new jobs, auto-track top N by score, overflow to backlog
3. `get_tracked_jobs` → for each unanalyzed job: `get_job_details` → `update_job_analysis` (or `mark_dismissed`/`mark_longshot` with inline backfill loop)
4. Render tracked/applied/dismissed/longshot/rejected/accepted into a React artifact
### Deduplication
- **Within-source**: `seen_posts.json` (HN comment IDs), `seen_waas.json` (WAAS job URLs). Auto-pruned after 6 months.
- **Cross-source**: `scan_all` deduplicates by company name (case-insensitive, stripped). HN takes priority since it has richer context.
- **First-run backfill**: Empty seen files trigger a 3-month HN backfill scan.
## Tech Stack
| Component | Technology | Purpose |
|-----------|-----------|---------|
| HN ingestion | `requests` + Algolia API | Parallelized comment fetching (20-worker ThreadPoolExecutor) |
| WAAS scraping | `playwright` | Headless browser with auth, infinite scroll handling |
| HTML parsing | `beautifulsoup4` | Job board scraping (Greenhouse, Lever, Ashby) |
| Resume parsing | `pymupdf` | PDF text extraction |
| Config | `pyyaml` | YAML config with runtime updates |
| LLM integration | `anthropic` (CLI) / MCP stdio (Desktop) | Resume-based job ranking |
| MCP server | `mcp` (FastMCP) | 20-tool stdio server for Claude Desktop |
| Filter pipeline | `re` (compiled word-boundary regex) | Shared across both sources |
| State management | JSON files (atomic read-modify-write) | 8 tracking stores + description cache |
## Setup
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp config.yaml.example config.yaml # edit with your preferences
```
### Environment Variables (`.env`, gitignored)
| Variable | Purpose |
|----------|---------|
| `ANTHROPIC_API_KEY` | Claude API (CLI ranking mode only) |
| `WAAS_USERNAME` / `WAAS_PASSWORD` | YC account for full WAAS access (~30 jobs without) |
| `HN_JOBS_EMAIL_TO` / `FROM` / `PASSWORD` | Email delivery (Gmail app password) |
| `TRACKING_DIR` | Directory for tracking JSON files (keeps paths out of git) |
### Claude Desktop MCP Config
```json
{
"mcpServers": {
"hn-jobs": {
"type": "stdio",
"command": "wsl",
"args": [
"bash", "-c",
"set -a; source /path/to/.env; set +a; /path/to/.venv/bin/python3 /path/to/mcp_server.py"
]
}
}
}
```
## CLI Usage
```bash
python3 hn_jobs.py --dry-run --no-rank # HTML preview, no ranking
python3 hn_jobs.py --no-email # Terminal output
python3 hn_jobs.py --dry-run --resume resume.pdf # Rank against resume
python3 hn_jobs.py # Full scan + email delivery
```
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues