Skip to main content
Glama
Ispas-Catalin

Romanian Job Search MCP

README.md
# Romanian Job Search MCP

Cache-first MCP server for Romanian job search. The first provider is Jooble, with a database ledger so Hermes can search locally most of the time and spend Jooble API requests only when asked.

## Design

- `search_logged_jobs` searches the local DB only. It never calls Jooble.
- `search_and_log_jooble_jobs` can spend one Jooble API request, then upserts every returned job.
- `start_job_search_session` lets Hermes pass a Jooble key from the agent-owned `.env`.
- Raw API keys are not stored in the database. Request logs store only `key_id` and a fingerprint.
- `mark_job_interest` stores interest/application state so the LLM does not need to remember.
- `get_job_detail_schema` defines the stable structure Hermes should fill after researching a job page.
- `populate_job_details` persists that LLM-filled structured detail record.

## Key Ownership

Recommended deployment: keep Jooble keys in the Hermes/agent `.env`, not in the MCP container/appdata. Hermes should read its fallback keys and pass the selected key as `api_key` when it calls `start_job_search_session`.

The MCP keeps that raw key only in process memory for the session. The database stores request counts with a key id/fingerprint, not the secret. Named MCP-side keys via `JOOBLE_API_KEYS` still exist as an optional mode, mainly for standalone testing or an MCP-owned scheduled worker.

## MCP Tools

- `start_job_search_session(api_key, request_budget?)`
- `quota_status(session_id?, key_id?)`
- `search_logged_jobs(keywords?, location?, marked_status?)`
- `search_and_log_jooble_jobs(session_id, keywords, location?, radius?, salary?, page?, result_on_page?)`
- `get_logged_job(job_id?, source_job_id?, include_raw?)`
- `get_job_detail_schema()`
- `populate_job_details(job_id, details, extracted_by?)`
- `mark_job_interest(job_id, status, notes?)`
- `list_marked_jobs(status?)`
- `create_saved_search(...)`
- `refresh_saved_search(session_id, saved_search_id)`

## Hermes Usage Pattern

1. Start a session with a key from the Hermes `.env`:

   ```json
   {"api_key": "${JOOBLE_RO_PRIMARY}", "request_budget": 3}
   ```

   If the primary key hits quota, Hermes can start a new session with its fallback key.

2. Search locally first:

   ```json
   {"keywords": "python backend", "location": "Bucuresti"}
   ```

3. Spend Jooble quota only if local results are missing or stale:

   ```json
   {
     "session_id": "returned-session-id",
     "keywords": "python backend",
     "location": "Bucuresti",
     "result_on_page": 50
   }
   ```

4. Mark jobs instead of remembering them in the LLM context:

   ```json
   {"job_id": 1, "status": "interested", "notes": "Remote-friendly Python API role"}
   ```

5. For deeper details, Hermes should inspect the public job page itself, then save a structured extraction:

   ```json
   {
     "job_id": 1,
     "details": {
       "job_title": "Python Developer",
       "company_name": "Example SRL",
       "company_website": null,
       "location": "Bucuresti",
       "work_mode": "hybrid",
       "employment_type": "full_time",
       "seniority": "mid",
       "contract_type": "CIM",
       "salary_text": null,
       "salary_min": null,
       "salary_max": null,
       "salary_currency": null,
       "description": "Concise role description extracted from the public page.",
       "responsibilities": ["Build APIs", "Maintain integrations"],
       "requirements": ["Python", "REST APIs"],
       "nice_to_have": [],
       "benefits": [],
       "technologies": ["Python", "FastAPI"],
       "languages": ["English"],
       "application_url": "https://ro.jooble.org/jdp/...",
       "source_url": "https://ro.jooble.org/jdp/...",
       "contact": null,
       "evidence": {
         "work_mode": "Short supporting note or snippet"
       },
       "missing_fields": ["salary_text"],
       "confidence": 82
     },
     "extracted_by": "hermes"
   }
   ```

   Use `get_job_detail_schema` when Hermes needs the exact current field contract. Unknown fields are rejected; unknown values should be `null` or `[]` and listed in `missing_fields`.

## Local Development

```powershell
py -m venv .venv
.\.venv\Scripts\python.exe -m pip install -e ".[test]"
.\.venv\Scripts\python.exe -m pytest
.\.venv\Scripts\python.exe -m uvicorn ro_job_search_mcp.main:app --reload
```

The default Docker Compose MCP endpoint is `http://localhost:8012/mcp`, and health is `http://localhost:8012/health`.

## Docker Compose / Unraid

1. Copy `.env.example` to `.env`.
2. Do not put Jooble keys in this `.env` when Hermes owns them.
3. On Unraid, set `APPDATA_PATH=/mnt/user/appdata/ro-job-search-mcp`.
4. Start:

   ```bash
   docker compose up -d --build
   ```

The MCP server listens on host port `${MCP_PORT:-8012}` and container port `8000`.

The `worker` service is behind the Docker Compose `worker` profile because it needs an MCP-side key. In the agent-owned key model, let Hermes call `refresh_saved_search` instead of running the worker.

## Publishing Docker Images

GitHub Actions publishes the Docker image to GitHub Container Registry:

```text
ghcr.io/ispas-catalin/ro-job-search-mcp
```

Publish by pushing a version tag:

```powershell
git tag v0.1.0
git push origin v0.1.0
```

You can also run the `Docker` workflow manually from the GitHub Actions tab.

## Jooble Quota Policy

Default limits are set for a 500-request key:

- monthly hard limit: `500`
- monthly soft warning: `430`
- daily limit: `16`
- default cache TTL: `12h`

Cached searches do not spend quota. Forced refreshes and stale cache refreshes do.