Skip to main content
Glama
singhalpooja9

JobScannerMCP

README.md
# JobScannerMCP

**One MCP server, any host.** A [Model Context Protocol](https://modelcontextprotocol.io)
server that exposes a job-posting scanner as clean, typed tools — then drives the
*unmodified* server from **two different agent hosts** (Claude and Goose) to show that
MCP is a portability layer, not a buzzword.

[![CI](https://github.com/singhalpooja9/JobScannerMCP/actions/workflows/ci.yml/badge.svg)](https://github.com/singhalpooja9/JobScannerMCP/actions/workflows/ci.yml)
![python](https://img.shields.io/badge/python-3.10%2B-blue)
![MCP](https://img.shields.io/badge/protocol-MCP-5eead4)
![status](https://img.shields.io/badge/status-working%20MCP%20server-5eead4)

> **What this is:** a small, working FastMCP server (search jobs, check scan health,
> score fit, draft outreach) with a **built-in guardrail** — plus the config to run it
> from Claude Code and a validated Goose recipe. Build the tools once; any MCP host can
> use them.
>
> **What this is not:** a live scraper or a product. It serves synthetic sample data so
> it runs offline with no keys and no private code.

---

## Why I built it

"MCP" gets said a lot; far fewer people have actually **built a server and consumed it
from more than one host.** I wanted a concrete artifact that shows the whole loop —
clean tool schemas, a governance guardrail, and genuine host-portability — on a domain I
know (a job scanner). The payoff is *"build once, integrate everywhere"*: the same four
tools work in Claude, Goose, Cursor, or anything that speaks MCP.

*(Clean-room + synthetic data only — no real résumé, no private scanner code, no
credentials.)*

---

## The idea in one picture

```
                    ┌─────────────────────────────┐
   Claude Code  ───▶│                             │
   (MCP client)     │   job-scanner  (FastMCP)    │
                    │                             │
   Goose        ───▶│   tools:  search_jobs        │──▶  synthetic
   (recipe)         │           get_company_health │     postings +
                    │           score_fit          │     company health
                    │           draft_outreach 🔒  │     (offline, no keys)
                    │   resource: companies://…    │
                    └─────────────────────────────┘
        two hosts, ONE unmodified server        🔒 = dry-run guardrail (never sends)
```

## Quickstart (no API key)

```bash
git clone https://github.com/singhalpooja9/JobScannerMCP
cd JobScannerMCP
pip install -e ".[dev]"

pytest -q                          # 8 in-memory MCP tests, fully offline
python -m jobscanner.server        # run the server over stdio
```

## The tools

| Tool | What it does | Notes |
|------|--------------|-------|
| `search_jobs(keywords, country, remote_only, limit)` | keyword search over postings | read-only |
| `get_company_health(company)` | per-company scan status (ok/blocked/…) | explains *why* a company returned nothing |
| `score_fit(job_id, profile)` | 0–100 fit + label for a posting | deterministic heuristic; for a **calibrated LLM judge** see [JobFitJudge](https://github.com/singhalpooja9/JobFitJudge) |
| `draft_outreach(job_id, tone)` | drafts a note — **`sent` is always `false`** | 🔒 the guardrail: the server *cannot* send |

Plus an MCP **resource** (`companies://registry`) and a reusable **prompt** (`find_roles`).

### The guardrail (governance by design)
`draft_outreach` is the only "write-ish" tool, and it is **dry-run only** — it returns a
draft with `sent: false` and never has a code path that sends. A host (or a hijacked
prompt) *cannot* make this server take a real-world action. That "the server enforces
safety, not the prompt" stance is the governance point, and it's covered by a test.

---

## Run it from two hosts

### Host 1 — Claude Code / Claude Desktop
Add the server from [`recipes/claude_mcp_config.json`](recipes/claude_mcp_config.json)
to your MCP config (set the absolute path to your clone), then ask Claude:
> *"Use search_jobs to find conversational-AI roles, score each with score_fit, and summarize the good ones."*

### Host 2 — Goose
The **same** server, wired into a validated Goose recipe
([`recipes/find_roles.yaml`](recipes/find_roles.yaml)):

```bash
goose recipe validate recipes/find_roles.yaml       # ✓ recipe file is valid
goose run --recipe recipes/find_roles.yaml --params keywords="conversational AI"
goose recipe deeplink recipes/find_roles.yaml       # shareable link
```

Same tools, two hosts, zero server changes — that's the whole point.

---

## Use it for your own scanner / data

Point the tools at your own postings — no code changes:
1. Replace [`data/postings.jsonl`](data/postings.jsonl) (one JSON posting per line:
   `id, company, title, description` required; `location, department, remote, url` optional).
2. Replace [`data/companies.yaml`](data/companies.yaml) with your company/health list.
3. Restart the server. Both hosts pick up the new data automatically.

To swap the naive `score_fit` heuristic for a real, calibrated LLM judge, drop in the
companion project **[JobFitJudge](https://github.com/singhalpooja9/JobFitJudge)**.

---

## What this repo demonstrates (concepts + stack)

| Concept | Where it lives |
|---|---|
| **MCP server** (tools + resource + prompt, typed schemas) | `jobscanner/server.py` |
| **MCP client / host portability** | `recipes/claude_mcp_config.json` + `recipes/find_roles.yaml` |
| **Guardrails / safe-by-design tools** | `draft_outreach` dry-run (`sent=false`), tested |
| **Tool schema design** | typed args + docstrings the model reads |
| **In-memory protocol testing** | `tests/` via FastMCP `Client(mcp)` — no subprocess |
| **Structured responses** | Pydantic models + Goose recipe `response.json_schema` |
| **Offline-first / deterministic CI** | synthetic data, green with zero API keys |

**Stack:** Python · **FastMCP** (Model Context Protocol) · Pydantic · PyYAML · pytest +
pytest-asyncio · GitHub Actions · **Goose** recipe · Claude MCP config.

## Project layout

```
jobscanner/
  server.py      # the FastMCP server: 4 tools + 1 resource + 1 prompt + guardrail
  store.py       # load synthetic postings + company health (offline)
  fit.py         # deterministic fit heuristic (swap for JobFitJudge's LLM judge)
  models.py      # Job + CompanyHealth (Pydantic)
data/
  postings.jsonl # synthetic postings   (REPLACE with your own)
  companies.yaml # synthetic company/health registry
recipes/
  claude_mcp_config.json  # Host 1: Claude Code / Desktop MCP config
  find_roles.yaml         # Host 2: validated Goose recipe
tests/           # 8 offline in-memory MCP tests
```

## Honest scope & limitations

- **Working MCP server, not a product.** Synthetic data, no live scraping, no auth.
- **`score_fit` is a naive keyword heuristic** — deliberately, so this repo stays about
  the *protocol layer*. Rigorous fit scoring lives in the companion JobFitJudge repo.
- **All shipped data is synthetic.** No real résumé, no private code, no credentials.

---

## Part of a larger series

Repo 2 of a small set exploring the agentic-AI ecosystem hands-on — evaluation
([JobFitJudge](https://github.com/singhalpooja9/JobFitJudge)), MCP (this repo),
multi-agent orchestration, RAG, and spec-driven development. More at
**[singhalpooja.com](https://singhalpooja.com)**.

---
*Built by [Pooja Singhal](https://singhalpooja.com) — Senior Technical Program Manager.*