job_ops-mcp
v0.13.1
Published
Self-hosted MCP server for the full job-search loop: portal scanning, JD evaluation, tailored resume + cover PDFs, outreach drafting, story bank, negotiation brief — chat-driven, human-in-the-loop.
Maintainers
Readme
job_ops-mcp
A self-hosted Model Context Protocol server for the full job-search loop — portal scanning, JD evaluation, tailored resume + cover PDFs, outreach drafting, story bank, negotiation brief — all driven from your MCP-aware chat client (Claude Desktop, Cursor, any client that speaks streamable-HTTP MCP).
The chat is the brain. This server executes the mechanical work and hands every artifact
back as an http://localhost:7891/... link.
Status: early. Works. APIs may still move pre-1.0.
Quickstart
# 1. Scaffold your working directory (cv.md, profile.yml, portals.yml, modes/*.md + SQLite DB)
npx job_ops-mcp init
# 2. Open cv.md, config/profile.yml, portals.yml and replace every <TODO> placeholder.
# (Optional: tune modes/*.md — rubric, tailoring rules, outreach tone — your edits win.)
# 3. Rebuild the career_packet from your now-real cv.md
# (or just re-run `init` — it auto-reseeds when it detects cv.md changed)
npx job_ops-mcp reseed
# 4. Confirm everything is wired
npx job_ops-mcp doctor
# 5. Boot the server (Chromium auto-installs on first run)
npx job_ops-mcp start
# ▷ job_ops-mcp listening on http://127.0.0.1:7891
# 6. Get a copy-paste config block for your MCP client
npx job_ops-mcp connect
# 7. Paste a job URL or pasted JD into your chat — the chat calls evaluate_job, draws on
# your rubric + career_packet + tailoring rules, and walks the rest of the workflow.That's the loop. Everything else (warm-intro finder, story bank, negotiation brief, batch rater, scheduler, …) is wired in but optional.
Editing the career packet — two safe directions
Your career_packet is the source of truth the chat uses to score JDs and draft materials.
You can drive edits from either direction, and neither one silently destroys the
other — both are explicit:
1. Chat-driven (the packet is your edit surface). Ask the chat to change a tagline,
remove a project, tighten a bullet — it calls update_career_packet, which writes a new
version and marks the packet user-edited. From then on a plain reseed will refuse
to overwrite it (it warns and tells you to pass force or sync first). Section edits are
ergonomic — "change my tagline" only re-sends Section 2, not the whole packet:
update_career_packet section="2" section_content='- **Builder PM** — "ships product with engineering teeth"'For surgical, item-level changes there's edit_packet_item / remove_packet_item —
address a section (projects, skills, taglines, or a number) and one item (by index or a
matching substring) to change or drop a single bullet/project/skill/tagline without touching
the rest. Every change still versions the packet, and edit_packet_item runs the
visa-leakage scan on the new text. remove_packet_item echoes exactly what it removed. Since
every change is a version, nothing is truly lost — restore_packet_version lists history
and brings any prior version back (restore is itself reversible).
2. File-driven (cv.md / profile.yml are the source). Edit cv.md /
config/profile.yml, then reseed to rebuild the packet from them:
npx job_ops-mcp reseed # safe: refuses if the packet has chat edits not in cv.md
npx job_ops-mcp reseed --force # rebuild from cv.md anyway (drops chat edits)Bringing the two back in sync. When you've been editing in chat and want cv.md to
catch up, run sync_packet_to_cv — it writes the packet back into cv.md + profile.yml
so the source files reflect your chat edits (and a later reseed reproduces them instead of
clobbering them). So the two directions are symmetric: reseed (cv.md → packet) and
sync-back (packet → cv.md), both explicit, neither automatic.
reseed writes a NEW active version (previous demoted, history kept). doctor reports when
the packet is chat-edited (expected — not a nag) vs when cv.md changed under a file-driven
packet. Standing policy with no CV/profile field (naming conventions, rendering rules, custom
guardrails) lives in modes/career_packet.md Section 9, which reseed always preserves.
Operator's guide / project memory:
docs/PROJECT_MEMORY.mdis a single self-contained reference (architecture, every tool, env vars, setup, the template system, sampling/elicitation/auth, hard rules, troubleshooting). Drop it into your MCP client's project memory so you can ask "how do I X?" and get answers.
What it does
Two systems merged into one MCP server:
- Evaluation + materials side — port of santifer/career-ops:
6-block A–F (+G legitimacy) report, archetype detection, ATS-friendly HTML→PDF resume
- cover generation, story bank, negotiation playbook.
- Pipeline side — port of a personal Postgres + n8n pipeline ("JSA"): Greenhouse / Ashby / Lever / Workday pollers + closed-board Playwright scrapers, content-hash dedupe, batch LLM rater with strict-JSON parsing, warm-intro / founder DM drafter, visa signal from DOL OFLC H1B data.
Everything lives in a single Node process with a single SQLite file. No external Postgres, no n8n, no cloud anything. Bring your own LLM key (Gemini free tier by default, DeepSeek optional) if you want the API/batch paths; chat-mode tools work without one.
Not affiliated with or endorsed by santifer's career-ops. This is an independent project that ports + adapts the publicly-released MIT-licensed templates and rubric shape into the MCP transport surface. See the Attribution section.
Tools (51 — one MCP tools/list call away)
| Group | Tools |
|---|---|
| Evaluation | evaluate_job, batch_evaluate, get_top_jobs, evaluate_training, evaluate_project |
| Materials | generate_materials, render_pdf (PDF / .tex / .docx), get_report |
| Tracker | get_tracker, update_status, mark_ready_to_apply, delete_jobs (soft-delete → trash), restore_jobs, list_trashed, purge_jobs (permanent, backup-first) |
| Sourcing | scan_portals (Greenhouse + Ashby + Lever + Workday + Amazon + Google + generic Playwright) |
| Outreach | find_warm_intros, find_founders, draft_outreach, draft_followup, draft_reply, get_outreach_queue, update_outreach, get_followups_due |
| Contacts | add_contacts (upsert from chat), export_contacts (CSV+JSON backup), import_contacts (merge, non-destructive), delete_contacts (soft-delete, recoverable) |
| Interview / offer | extract_stories, get_story_bank, negotiation_brief |
| Research | deep_research, enrich_company, daily_digest |
| Profile + ops | get_career_packet, update_career_packet (whole-doc), edit_packet_item / remove_packet_item (one bullet/project/skill/tagline), restore_packet_version (history + restore), reseed_career_packet (safe by default), sync_packet_to_cv, update_profile (elicitation), cost_estimate, doctor (read-only health) |
| Apply (preview only — never submits) | apply_prefill |
| Visa (optional, can be hidden) | visa_signal, import_h1b, import_linkedin |
| Scheduler (opt-in cron, off by default) | scheduler_status, scheduler_enable, scheduler_disable |
Six MCP resources carry the editable behaviour — rubric, report_format,
tailoring_rules, outreach_tone, negotiation_playbook, career_packet — all loaded from
modes/*.md and live-reloaded on edit. Tune scoring or tone without touching code.
Tip: ask the chat to run
doctoranytime — it's a read-only health report (same checks as thenpx job_ops-mcp doctorCLI command) covering packet ↔ cv.md sync state, LLM provider/key, sampling + auth posture, active template, modes, visa scoring, and the public base URL. Handy for "is my server wired right?" without leaving chat.
Trash & recovery (jobs)
Jobs follow the same non-destructive, recoverable, echo-before-delete philosophy as contacts and the career packet. Soft-delete is the default; hard delete is always explicit, confirmed, and backup-first.
delete_jobsmoves 1..N jobs to the trash (byjob_idsand/or bystatuses— e.g. trash everything inskip/discard/sourced). Trashed jobs drop out of the tracker,get_top_jobs, and batch rating but are retained and restorable. It echoes exactly which jobs (title + company) were trashed. This is recoverable — not a hard delete.list_trashedshows what's currently in the trash (title, company, score, prior status, when trashed) so you can review before restoring or purging.restore_jobsbrings trashed jobs back to their prior state.purge_jobsis the only hard delete: it permanently removes trashed jobs (never live ones). Passjob_ids, orpurge_all: true(which requiresconfirm: true). A timestamped backup of the affected rows is written to the project root before deletion; it echoes exactly what was permanently removed.
In the tracker UI (/): the Status cell is an inline dropdown (edit in place), and each
row has a compact Trash button (soft-delete; the row leaves and the summary cards update
live). A dedicated /trash page lists trashed jobs where you can Restore an
item, Delete permanently one (with a confirm dialog), or Empty trash (a clearly-warned,
irreversible hard delete — backup written regardless). UI actions hit the same /api/*
endpoints that share the exact core logic the chat tools use, so a job trashed in chat shows up
on /trash and vice-versa.
Filtering, search & pagination (tracker)
At 1000+ jobs the tracker doesn't ship every row to the browser — it paginates server-side
(SQL WHERE/LIMIT/OFFSET + a COUNT, never in-memory). The dashboard at / has:
- Filters (combinable): multi-select status, min/max score, company (contains, with a datalist of present companies), role and seniority, and a show trashed toggle.
- Search: debounced, case-insensitive substring on title or company.
- Pagination: page-size selector (25/50/100, default 50), first/prev/next/last + jump-to-page, and a "N matching · showing X–Y" indicator.
- Sort: click the Score / Company / Discovered headers (default score ↓).
- Shareable URLs: every filter/sort/page is in the query string
(
/?status=applied&min_score=70&q=engineer&sort=score&page=2) — bookmarkable and survives refresh. - The summary cards stay TRUE TOTALS — the whole active pipeline, independent of the current filter/page. Inline status edits and trashing keep working within a filtered/paged view (the row updates/leaves and counts refresh without losing your place).
The same query powers the get_tracker MCP tool, so chat can ask for slices too — e.g.
"show me applied jobs scored over 70 with 'engineer' in the title." get_tracker accepts
statuses[], min_score/max_score, company, role_category, seniority, q, sort,
dir, limit, offset, and show_trashed, and returns items + total_matching +
full-pipeline counts_by_status.
Scoring without an LLM key — IF your client supports MCP sampling
The scoring tools (batch_evaluate, evaluate_job mode="api") can run on your
connected client's model via MCP sampling — same rubric, same strict-JSON contract,
no separate Gemini/DeepSeek key — but only if the connected client advertises the
sampling capability in its initialize handshake.
⚠️ Most clients don't (yet) — including Claude Desktop, as of now. Claude Desktop advertises only its UI extension, never
sampling. The transport (stdio vs HTTP) is not sufficient on its own — it's a per-client capability. So on Claude Desktop and similar clients, batch/api scoring falls back to the BYO key (MCP_JSA_LLM_PROVIDER+ key), which is expected and correct. Check current support at modelcontextprotocol.io/clients.
- It engages automatically if (and only if) a sampling-capable client connects — no
configuration needed. The gate checks both the client's advertised
samplingcapability and that the transport can carry the server→client request (stdio; the stateless HTTP transport can't, so it never even tries). When sampling isn't available, scoring uses the BYO key; if that isn't set either,evaluate_job mode="chat"(the default) still works — your chat scores it directly. Fallback is clean (no hang). - Bottom line for Claude Desktop users: set
MCP_JSA_LLM_PROVIDER+ the matching key forbatch_evaluate/evaluate_job mode="api". (Plainmode="chat"needs no key.) - Cost. When sampling is used it runs on the client's model, so the cost is borne by
the client;
cost_estimaterecords those calls flagged client-borne ($0 server cost). - Run the
doctortool to see the live state — it reports whether your current client advertised sampling or whether you're on the BYO-key path. - Set
MCP_JSA_SAMPLING=falseto force the BYO-key path even when sampling is available.
Frictionless profile setup (MCP elicitation)
update_profile uses MCP elicitation (form mode) so your client can collect identity
fields + per-archetype taglines through a structured form — no hand-editing YAML. On
accept it writes config/profile.yml and reseeds the career packet in one step.
Sensitive inputs (your LinkedIn export path, credentials) use URL-mode elicitation
(2025-11-25): the server hands you a one-time local URL where you enter the value
directly, so it never passes through the MCP client / chat transcript. import_linkedin
uses this when you omit path and your client supports it.
Both are capability- and transport-gated (like sampling, elicitation is a server→client
request that needs a stdio connection). Clients without elicitation support — and all HTTP
clients — fall back to the file-based + argument paths (update_profile fields=…, edit
config/profile.yml, pass import_linkedin path=…), which work exactly as before.
Designed to be made yours
The defaults assume nothing about your location, citizenship, role, or industry. Every
behaviour-shaping piece is a markdown file you can rewrite. init copies these into
<project-root>/modes/ so they're yours to edit — the loader reads your project-root
copy first and falls back to the bundled default, so you never touch the package install.
A re-init never clobbers an edited copy (it warns and keeps your edits); doctor reports
which files are user-overridden vs bundled.
| You can change… | By editing… |
|---|---|
| Scoring dimensions + weights | modes/rubric.md |
| 6-block report shape | modes/report_format.md |
| Resume/cover tailoring rules | modes/tailoring_rules.md |
| Outreach tone + char caps | modes/outreach_tone.md |
| Negotiation scripts + framework | modes/negotiation_playbook.md |
| Your bullet/project bank | modes/career_packet.md (or via update_career_packet) |
| Per-archetype taglines | config/profile.yml → taglines: (auto-fills career-packet Section 2 on reseed) |
| Tracked companies + filters | portals.yml |
| Identity + target roles | config/profile.yml |
Non-US users / non-sponsorship cases
Visa scoring is fully optional. Set:
export MCP_JSA_VISA_SCORING=falseWhen off:
score_total = round(0.6 · resume_fit + 0.4 · taste_fit)(server-side authoritative)- The
visa_signal,import_h1b,import_linkedintools are hidden fromtools/list score_visa_fitis stripped fromget_top_jobsitems and the eval-report HTML badge- The rubric resource gets a "VISA SCORING DISABLED" override prefix the chat reads
Other features are unaffected. If you're a US citizen, a non-US user, or anyone scoring roles where sponsorship is a non-issue — turn it off; the rest of the system works.
Non-US markets
portals.yml ships with example shapes for Greenhouse / Ashby / Lever / Workday / Amazon
/ Google / generic Playwright. Drop in the boards relevant to your market. modes/rubric.md
modes/negotiation_playbook.md+config/profile.ymlare all yours to localize (language, comp ranges, geography priors).
Environment variables
| Var | Default | Purpose |
|---|---|---|
| MCP_JSA_PORT | 7891 | HTTP port (MCP + file server + tracker dashboard) |
| MCP_JSA_HOST | 127.0.0.1 | Bind host |
| MCP_JSA_PROJECT_ROOT | cwd | Where cv.md / config/profile.yml / portals.yml live |
| MCP_JSA_DATA_DIR | <install>/data | SQLite + WAL location |
| MCP_JSA_OUTPUT_DIR | <install>/output | Rendered artifacts (PDFs, report HTML) |
| MCP_JSA_VISA_SCORING | true | Set false to drop visa surface entirely (see above) |
| MCP_JSA_TEMPLATE_DIR | empty | User-owned dir holding additional resume/cover themes — overrides bundled themes of the same name. See Custom themes + TEMPLATES.md. |
| MCP_JSA_DEFAULT_TEMPLATE | default | Theme used when render_pdf has no explicit template argument. |
| MCP_JSA_PUBLIC_BASE_URL | empty | Public URL emitted in artifact links. Default: http://127.0.0.1:<port>. Set when running on a remote host (Tailscale, LAN, etc.) — see Running on a remote host. |
| MCP_JSA_AUTH_TOKEN | empty | Bearer token gating /mcp, /files/*, and the dashboard. Required to bind to anything other than localhost — without it, a non-localhost bind refuses to start (default-deny). See Security model. |
| MCP_JSA_SAMPLING | true | Use MCP sampling for api/batch scoring when the connected client advertises it (most, incl. Claude Desktop, don't — then the BYO key is used). Set false to always use the BYO key. |
| MCP_JSA_LLM_PROVIDER | gemini | BYO-key path for api/batch scoring — used whenever the client doesn't support sampling (the common case): gemini, deepseek, none |
| MCP_JSA_LLM_MODEL | empty | Provider-specific model id |
| GEMINI_API_KEY / DEEPSEEK_API_KEY | empty | Provider credentials — needed for api/batch scoring unless your client supports MCP sampling (most don't; mode="chat" never needs a key) |
| MCP_JSA_SCHEDULER_ENABLED | false | Whether opt-in cron runs at all |
A working starter is at .env.example.
Run one server, use every client
The recommended multi-client topology: ONE long-running HTTP server = ONE source of truth. Start it once, point every interface at it:
npx job_ops-mcp start # HTTP mode — serves MANY concurrent MCP clients
npx job_ops-mcp connect # prints ready-to-paste config for each client belowAll clients connect to the same http://127.0.0.1:7891/mcp (or a Tailscale host:port —
see Running on a remote host). Materials,
tracker moves, contacts, packet edits made in any client are instantly visible in
all the others, because there is exactly one process and one SQLite DB behind them.
Rate-limited in one client? Switch to another — nothing is lost.
| Client | Config (all printed by connect) |
|---|---|
| Claude Code | claude mcp add --transport http job_ops-mcp <url> or .mcp.json ("type": "http") |
| Claude Desktop | mcp-remote bridge in claude_desktop_config.json (stdio→HTTP), or a paid-plan custom connector |
| opencode | opencode.json → "mcp": { … "type": "remote", "url": … } |
| codex | ~/.codex/config.toml → [mcp_servers.job_ops_mcp] with url + bearer_token_env_var |
| gemini-cli | ~/.gemini/settings.json → "httpUrl" + "headers" |
| LibreChat | librechat.yaml → type: streamable-http (see below for Docker) |
| Web UI / browser | the tracker dashboard at / is the same server, same DB |
Known issue (mcp-remote × Node ≥ 26):
mcp-remote(≤ 0.1.38) fails under Node 26+ withStreamableHTTPError: Unexpected content type: null— its bundled undiciEnvHttpProxyAgentglobal dispatcher strips response headers from Node's built-infetch. The server's responses are spec-correct; the bridge mangles them client-side. Until fixed upstream, run the bridge under Node ≤ 24: replace"command": "npx"with an absolute path to a Node 24 binary and pointargsat a Node-24-installedmcp-remote(npm i -g mcp-remoteunder that Node). Symptom appears in Claude Desktop'smcp-server-*.logright afterUsing transport strategy: http-first.
Concurrency is safe by design: each HTTP request gets an isolated MCP protocol instance, reads run concurrently (SQLite WAL), and all writes are serialized through one write lock in the single server process — no corruption under multi-client load, no per-client spawn, no port conflicts.
Verify everything is wired to the same instance:
npx job_ops-mcp status # uptime, source-of-truth DB path + fingerprint,
# clients seen since boot (add --url / --token as needed)The doctor tool reports the same server-identity block from inside any chat client.
Auth: the moment the server is reachable beyond localhost (Tailscale / LAN /
always-on box), you must set MCP_JSA_AUTH_TOKEN — it serves PII (resume, contacts,
H1B data) to every connected endpoint, and it refuses to boot remotely without the
token (see Security model). The token then goes into each client's
config; connect embeds it automatically when the env var is set.
Wiring it to Claude Desktop (stdio transport)
Single-client alternative. This spawns a private server inside Claude Desktop
rather than connecting to the shared one — fine when Claude Desktop is your only
client. For the shared topology above, use the mcp-remote bridge that connect
prints instead. (A stdio instance next to a running shared server also needs its own
MCP_JSA_PORT, or the HTTP file server inside it fails with EADDRINUSE.)
Claude Desktop's local MCP only speaks stdio, not HTTP. Use the --stdio flag:
{
"mcpServers": {
"job_ops-mcp": {
"command": "npx",
"args": ["-y", "job_ops-mcp", "start", "--stdio"],
"env": {
"MCP_JSA_PORT": "7891",
"MCP_JSA_PROJECT_ROOT": "/absolute/path/to/your/job-search/dir"
}
}
}
}(Drop into ~/Library/Application Support/Claude/claude_desktop_config.json on macOS,
%APPDATA%/Claude/claude_desktop_config.json on Windows. Restart Claude Desktop.)
In --stdio mode the MCP transport rides stdin/stdout (which Claude Desktop drives via
the npx spawn); the HTTP file server still binds to MCP_JSA_PORT in the background
so the http://127.0.0.1:7891/files/* links the server returns continue to resolve in
your browser.
Generic MCP clients that take a streamable-HTTP URL: skip the --stdio flag, run
npx job_ops-mcp start in a terminal, and point your client at
http://127.0.0.1:7891/mcp.
npx job_ops-mcp connect prints both blocks ready to paste.
LibreChat
npx job_ops-mcp connect also prints a librechat.yaml block. Two shapes:
- LibreChat as a host process:
type: streamable-http,url: http://127.0.0.1:7891/mcp. - LibreChat in Docker: swap to
http://host.docker.internal:7891/mcpAND allowlist the address undermcpSettings.allowedAddresses(LibreChat blocks private/internal addresses by default as SSRF protection). On Linux, also addextra_hosts: ["host.docker.internal:host-gateway"]to the LibreChat service in yourdocker-compose.yml.
(Refs: librechat.ai/docs/.../mcp_servers, features/mcp.)
Working evaluate_job payloads
Step 1 — paste a JD or URL
{
"method": "tools/call",
"params": {
"name": "evaluate_job",
"arguments": {
"input": "https://jobs.ashbyhq.com/example/123",
"mode": "chat",
"title": "Builder PM",
"company": "Frontier AI Tools Co"
}
}
}Returns the rubric, the report format, the active career packet, and a job_id. The
chat client uses those to score + draft the 6 blocks.
Step 2 — finalize
{
"method": "tools/call",
"params": {
"name": "evaluate_job",
"arguments": {
"job_id": "<from step 1>",
"mode": "chat",
"report": {
"archetype_detected": "Agentic / LLMOps hybrid",
"block_role_summary": "…",
"block_cv_match": "…",
"block_level": "…",
"block_comp": "…",
"block_personalize": "…",
"block_interview": "…",
"block_legitimacy": "…",
"keywords": ["builder pm", "agentic workflows", "…"]
},
"scores": {
"resume_fit": 86, "taste_fit": 92, "visa_fit": 88, "score_total": 88,
"reasoning": "Strong match on agentic workflows + PRDs + SQL/Python.",
"concerns": "Evals experience is adjacent rather than LLM-eval-specific.",
"role_category": "pm",
"seniority": "senior"
}
}
}
}Server persists, renders HTML at /files/reports/<id>.html, returns the URL.
Running on a remote host / Tailscale
By default every artifact link the server returns starts with http://127.0.0.1:<port>.
That's fine when you run server + chat on the same machine. If you run the server on a
cloud instance, a homelab box, or anything you reach over Tailscale / LAN / a tunnel,
127.0.0.1 on the link resolves to your chat machine — not the server — and the
links don't work.
Set MCP_JSA_PUBLIC_BASE_URL to the URL the chat machine actually uses to reach the
server:
# Tailscale magic DNS
export MCP_JSA_PUBLIC_BASE_URL="https://jobs.example.ts.net"
# Tailscale 100.x IP
export MCP_JSA_PUBLIC_BASE_URL="http://100.64.0.5:7891"
# LAN IP
export MCP_JSA_PUBLIC_BASE_URL="http://192.168.1.20:7891"
# Reverse proxy
export MCP_JSA_PUBLIC_BASE_URL="https://jobs.example.com"Every artifact link (resume PDF, .tex, .docx, eval report, apply_prefill screenshot,
tracker URL) now uses that base. The server still binds to MCP_JSA_HOST (default
127.0.0.1); to accept connections from other devices, also set MCP_JSA_HOST=0.0.0.0
— which now requires MCP_JSA_AUTH_TOKEN (see Security model).
npx job_ops-mcp doctor prints the effective public base URL and auth posture.
A malformed value (e.g. not-a-url) is rejected at boot with a warning on stderr; the
server keeps running with the default 127.0.0.1 base. Trailing slashes are stripped.
Security model
This server handles PII: your resume PDFs, cover letters, eval reports, your LinkedIn connections, and H1B-derived employer data. The auth posture is decided entirely by where you bind and whether a token is set:
| Bind (MCP_JSA_HOST) | MCP_JSA_AUTH_TOKEN | Result |
|---|---|---|
| 127.0.0.1 (default) | unset | Open — frictionless local use. PII stays on loopback. |
| 127.0.0.1 | set | Token required — bearer auth enforced even locally (opt-in). |
| 0.0.0.0 / LAN / Tailscale | unset | Refuses to start (default-deny). |
| 0.0.0.0 / LAN / Tailscale | set | Token required — bearer auth on every PII route. |
When a token is required, every PII-bearing route — the MCP endpoint (/mcp), the file
server (/files/*), and the tracker dashboard (/) — demands an
Authorization: Bearer <token> header. Requests without it get 401 with a
WWW-Authenticate header pointing at the protected-resource metadata document
(/.well-known/oauth-protected-resource). This aligns with the MCP 2025-06-18 model of
treating the server as an OAuth Resource Server, to the extent practical for a
self-hosted single-user tool: one operator-provisioned static token, no full
authorization-server flow.
# Expose over Tailscale/LAN — generate a strong token first.
export MCP_JSA_HOST=0.0.0.0
export MCP_JSA_AUTH_TOKEN="$(openssl rand -hex 32)"
export MCP_JSA_PUBLIC_BASE_URL="https://jobs.example.ts.net"
npx job_ops-mcp startWhat's protected: /mcp, /files/*, /. What's open by design: /healthz
(liveness, no PII) and the discovery metadata. Hard rule: PII must never be served
unauthenticated to a network — the default-deny boot guard exists precisely so a missing
token fails loudly instead of silently exposing your data. Still prefer a private network
(Tailscale / VPN / authenticated reverse proxy) over the public internet.
Downloadable, editable source formats
render_pdf produces the resume and cover in any subset of three formats:
| Format | Where it lands | Use it for |
|--------|-----------------|---------------------------------------------------------------|
| pdf | /files/pdfs/ | The deliverable. Light/white background, ATS-clean. |
| tex | /files/tex/ | The editable LaTeX source. Compiles with vanilla pdflatex. |
| docx | /files/docx/ | Word / Google Docs editing. Real headings + bullets, ATS-safe. |
Default is formats: ["pdf"] for back-compat. Request any subset:
{
"method": "tools/call",
"params": {
"name": "render_pdf",
"arguments": {
"job_id": "<from evaluate_job>",
"kind": "both",
"formats": ["pdf", "tex", "docx"],
"cover_body": "I am reaching out about ..."
}
}
}All URLs persist onto the application row in the rendered_files JSON column so
get_tracker, apply_prefill, and daily_digest can find them later. Re-rendering
one format merges into the existing map — never clobbers the others.
All formats in one call share a single content snapshot taken when the call starts:
cv.md + profile.yml, the active career packet (chat edits count — no sync-back
needed), and the job's persisted tailored materials (generate_materials output,
current materials_v). So editing and recompiling the .tex reproduces the same
document, and re-rendering after a packet edit or a new materials version produces
the updated one. The visa-leakage rail runs against every output format before
files are written.
Custom themes
render_pdf accepts a template argument — pick a named theme to render with.
Themes are directories under templates/themes/<name>/ holding any of:
resume.tex cover.tex resume.html cover.htmlOut of the box you get default. To author your own:
mkdir -p ~/job-themes/compact
# Author resume.tex / cover.tex / resume.html / cover.html in there.
# Each theme file is a plain template with {{PLACEHOLDER}} slots — see
# TEMPLATES.md for the full placeholder contract.
export MCP_JSA_TEMPLATE_DIR=~/job-themes
npx job_ops-mcp templates # lists bundled + user themes
# Then in your MCP chat:
# render_pdf job_id=... kind=both formats=["pdf","tex"] template="compact" cover_body="..."The loader checks $MCP_JSA_TEMPLATE_DIR first, so a default/ directory
inside your themes dir overrides the bundled default. Set MCP_JSA_DEFAULT_TEMPLATE
to make a non-default theme the implicit default for every call.
| Env var | Default | What it does |
|---------|---------|--------------|
| MCP_JSA_TEMPLATE_DIR | empty | Extra dir holding your custom themes (one subdir per theme). |
| MCP_JSA_DEFAULT_TEMPLATE | default | Theme used when render_pdf has no explicit template arg. |
A custom theme that omits a placeholder degrades gracefully (the section is dropped,
the renderer does not crash). In .tex themes, commenting a placeholder out
(% {{SUMMARY_SECTION}}) drops the section the same way — substitution skips
LaTeX comments. A malformed theme (missing \documentclass, missing
\begin{document}, etc.) returns a clear error naming the theme + file — pdflatex's
own backtrace never reaches the user. The visa-leakage scan and ATS hard rules apply
regardless of which theme you pick.
See TEMPLATES.md for the full placeholder reference + an example
custom theme.
.docxis generated programmatically and does not use themes. The Word file follows a fixed Calibri / heading-style layout for ATS friendliness; edit the output in Word if you need visual variation.
Advanced / outreach features (optional)
Importing your LinkedIn network → warm-intro finder
Download your LinkedIn data export (Settings → Data Privacy → Get a copy of your data → Connections), then:
# Through your MCP chat:
import_linkedin path="/absolute/path/to/Connections.csv"Now find_warm_intros(company="…") returns the people you actually know who work there
(filtered to non-recruiters, sorted by engineering / leadership weight).
Adding contacts from chat (no CSV). Found someone useful mid-search, or want to capture a
few people without a bulk export? Use add_contacts — it takes an array of 1..N contacts
in one call and upserts them into the same store, so they show up in find_warm_intros /
find_founders immediately:
add_contacts contacts=[
{ "full_name": "Dana Lee", "company": "Anthropic, Inc.", "title": "Staff Engineer",
"linkedin_url": "https://linkedin.com/in/dana-lee" },
{ "full_name": "Sam Park", "company": "Vercel", "title": "Head of Talent" }
]Only full_name is required. It matches existing people (by linkedin_url, else
full_name + company) so there are no silent duplicates; merges on update (omitted
fields are preserved); resolves company names with the same fuzzy normalization as the CSV
path; and infers is_recruiter / is_engineering / is_leadership from the title unless you
pass them. Partial contacts are stored and the per-contact result reports what was missing
(no linkedin_url, company unmatched, …) so the chat can ask you to fill the gaps. (Claude
parses your free-text/pasted contact info into these fields before calling.)
Backup, portability, and safe deletion — all non-destructive, sharing one philosophy (timestamped backups before anything risky; deletes are reversible):
export_contactswrites every contact and field (flags, notes, email, resolved company, ids, archived state) to timestampedcontacts_export_*.csvand.jsonin the project root. Your backup / portability path.import_contacts path="…"reads a.json/.csv(e.g. a prior export) and upserts / merges — never delete-and-replace: blank/absent fields never overwrite richer existing data, existing rows match (no duplicates), new rows insert. Re-importing an export is idempotent — same DB, zero loss. A backup is written before the import.delete_contactssoft-deletes 1..N people (bylinkedin_url,full_name+company, orid). Archived contacts vanish fromfind_warm_intros/find_foundersbut stay recoverable in the row; a backup is written first; and the result echoes exactly which rows matched (name + company + url) so a wrong fuzzy match is caught.
Guarantee: export → (edits happen) → re-import never destroys data not present in the imported file. The imported file is additive/updating, not authoritative-overwrite.
Company names are matched fuzzily so legal-name variants line up: import_linkedin,
import_h1b, JD ingestion, visa_signal, and find_warm_intros all normalize names by
stripping common legal suffixes (Inc, LLC, PBC, Ltd, Corp, Co, GmbH, …), lowercasing, and
trimming punctuation. So a LinkedIn connection at "Anthropic", an H1B filing under
"ANTHROPIC PBC", and a JD scraped as "Anthropic, Inc." all resolve to the same company
row — which is what makes warm-intro and visa-signal joins actually work. Resolved
variants are recorded in the company_aliases table.
Importing DOL OFLC H1B data → visa-friendliness signal
Download a quarterly LCA disclosure CSV from https://www.dol.gov/agencies/eta/foreign-labor/performance, then:
# Through your MCP chat:
import_h1b path="/absolute/path/to/LCA_Disclosure_Data_FY2025_Q1.csv"visa_signal(company="…") then returns a friendliness band (strong | mixed | weak |
none) computed from filings count + recency. Internal only — never surfaced in any
resume, cover letter, or outreach (see the visa hard rule).
If you disabled visa scoring (MCP_JSA_VISA_SCORING=false), these tools don't appear in
tools/list at all.
Scheduler (opt-in cron)
Off by default. To run scans + batch rates on a schedule:
# In your MCP chat:
scheduler_enable jobs=["scan_portals_4h", "batch_evaluate_30m", "daily_digest_morning"]Job cadence is fixed (4h / 30m / hourly with an 8AM digest window). Toggle off with
scheduler_disable. Survives only as long as the server process is alive.
Hard rules baked in
- Never surface visa / work-auth in any resume, cover letter, or outreach. Visa data is internal scoring only.
- Never invent claims not in
career_packet. The materials generator validates LLM output against the packet before persisting. - Human-in-the-loop everywhere. No tool auto-submits an application or auto-sends a
DM.
apply_prefillis preview-only — it opens the form in Chromium, drafts values, takes a screenshot, and stops. You submit manually. - Strict-JSON parsing on the api path with a recorded
PARSE_ERRORfallback — never silent zeros. - Tracker / application / outreach writes are serialized behind a single write lock.
Layout
job_ops-mcp/
├── modes/ # MCP resources (edit me to tune the brain)
├── templates/ # CV HTML/LaTeX templates + cover-letter template
├── fonts/ # Space Grotesk, DM Sans (woff2 subsets)
├── cv.example.md # → cv.md after init
├── config/profile.example.yml # → config/profile.yml after init
├── portals.example.yml # → portals.yml after init
├── src/ # TypeScript source (not published)
│ ├── cli.ts # init / start / doctor / connect
│ ├── server.ts # HTTP + MCP boot
│ ├── core/ # llm, providers, jobs, reports, render, scan_engine, …
│ ├── http/ # express app + dashboard
│ ├── mcp/ # define + register + tools/
│ └── migrations/*.sql # SQLite migrations
└── data/, output/ # gitignored runtime stateAttribution
- The HTML CV template, font set, and ATS unicode-normalization logic are ported from santifer/career-ops (MIT licensed). The 6-block A–F report shape, scoring rubric framing, and outreach tone rules are also inspired by that project. Not affiliated with or endorsed by career-ops — this is an independent fork of those publicly-released ideas into the MCP transport.
- The 3-dimension scoring formula (resume / taste / visa), the schema shape (companies / jobs / outreach / enrichment / career_packet views), and the strict-JSON rater rubric are distilled from a personal pipeline ("JSA") that predates this project.
Releasing (maintainer notes)
Releases ship to npm via the GitHub Actions workflow at
.github/workflows/publish.yml. The workflow fires
only on pushing a version tag (v*.*.*) — never on a push to main — so merging
work never auto-publishes.
One-time setup
- Generate an npm automation token at npmjs.com → click your avatar → Access Tokens → Generate New Token → choose "Automation" (NOT Read-Only and NOT Publish; Automation tokens bypass 2FA, which CI needs).
- Add it to GitHub. In the repo → Settings → Secrets and variables →
Actions → New repository secret → name
NPM_TOKEN, value the token you just copied (starts withnpm_).
Cutting a release
# 1. Bump the version in package.json. Either edit by hand, or:
npm version patch # 0.3.0 → 0.3.1 (also creates a git commit + tag)
# npm version minor # 0.3.0 → 0.4.0
# npm version major # 0.3.0 → 1.0.0
# 2. If you edited package.json by hand instead of `npm version`, commit it:
# git add package.json && git commit -m "release: vX.Y.Z"
# git tag vX.Y.Z
# 3. Push the commit + tag.
git push && git push origin vX.Y.ZThat tag push triggers publish.yml, which:
- Checks out the tagged commit.
- Sets up Node 20 with the npm registry.
- Verifies the tag (
vX.Y.Z) matchespackage.json'sversion— fails fast on a typo. npm ci+npm run build.npm publish --access public --provenance— provenance attaches a sigstore attestation visible on npmjs.com showing exactly which GitHub Actions run produced the tarball.
Watch progress in the repo's Actions tab. On success the new version appears on npmjs.com/package/job_ops-mcp.
Contributing / feedback
Issues + PRs welcome. There's no contributor guide yet — open an issue first if you're planning a large change.
MIT — see LICENSE.
