Skip to main content
Glama
jsundquist

job-search-mcp

by jsundquist

job-search-mcp

An MCP server that retrieves resume/experience evidence relevant to a job description via RAG, and tracks fit-analysis results in a tracking store.

Analysis and tracking only — this project does not generate or tailor resumes. See docs/adr/0007-analysis-only-v1-no-resume-generation.md.

Design

The server is adapter-based rather than locked to any specific vendor. Three interfaces define the boundaries:

  • VectorStore (src/job_search_mcp/vector_store/) — embedded resume/experience chunk storage and similarity search. Default implementation: QdrantVectorStore (Cloud or self-hosted-in-Docker — a connection detail, not an interface difference).

  • TrackingStore (src/job_search_mcp/tracking_store/) — persistence for fit-analysis results. NotionTrackingStore is the store the server wires up today (see Setup). SQLiteTrackingStore is a zero-dependency local implementation that also exists but isn't yet selectable via config — see Roadmap. Both read a user-declared tracking_schema.yaml (docs/adr/0011-configurable-tracking-field-schema.md) for which fields to write and read, rather than a mapping hardcoded to one specific Notion database.

  • ResumeSource (src/job_search_mcp/resume_source/) — retrieval of resume/experience content. Implementation: FileResumeSource (local text/markdown, PDF, and DOCX parsing).

Rationale for each of these decisions is recorded in docs/adr/.

Embeddings (src/job_search_mcp/embeddings/) default to a local SentenceTransformersEmbedder (all-MiniLM-L6-v2) — see docs/adr/0006-embeddings-choice-open.md.

Related MCP server: Job Application MCP

Stack

  • Python, dependency management via uv

  • just as the task runner (see justfile)

  • Qdrant (vector store), Notion (tracking store)

  • MCP Python SDK for the server itself

MCP tools

  • match_job(job_description, source_url=None) — embeds the job description, retrieves the most relevant resume/experience chunks from the configured VectorStore, and returns a heuristic retrieval_score (top-match cosine similarity) plus the retrieved evidence. Named retrieval_score, not fit_score — it's a retrieval confidence signal, not a fit judgment, and the two can diverge (see docs/adr/0008-resume-chunking-strategy.md). It does not synthesize a fit verdict itself — no internal LLM call — so the calling assistant is expected to reason over the returned evidence, applying the job-fit://rubric resource returned alongside it. Since job_description is often scraped/pasted web text, the result also includes it wrapped in an explicit data delimiter with a do-not-follow-instructions notice (docs/adr/0017-delimit-job-description-in-match-job-result.md).

    Note: docs/adr/0009-caller-agnostic-reversal.md calls for this judgment step to move server-side into a new evaluate_fit tool, so fit-bucket assignment isn't left to whichever assistant happens to call match_job. That tool is designed (docs/evaluate_fit_schema.md, docs/adr/0010-layer-split-design-evaluate-fit.md) but not yet implemented — today, the calling assistant still constructs the FitVerdict passed to push_to_tracker itself.

  • push_to_tracker(job_id, verdict, dry_run=False) — writes a FitVerdict (see docs/evaluate_fit_schema.md) to the configured TrackingStore. Updates an existing tracked row by Notion page ID — never creates a new row or searches for one. Only the fields your tracking_schema.yaml marks tool-populated are touched; every manual field on the row (company, comp range, source, work arrangement, etc.) is left as-is (docs/adr/0011-configurable-tracking-field-schema.md). A misconfigured field (e.g. a notion.property that no longer exists on your database) is skipped with a warning rather than failing the whole write — check the result's warnings. dry_run=True returns the mapped properties payload without writing.

  • find_or_create_application(company, role, source_url=None, dry_run=False) — finds an existing tracked row by exact company+role text match, or creates a new one if none exists. Requires company and role fields with a notion.property declared in tracking_schema.yaml. Returns {"job_id": ..., "created": bool}. See docs/adr/0013-find-or-create-application.md.

  • update_status(job_id, status, dry_run=False) — sets the Status field on an existing tracked row directly, for candidate-reported lifecycle events (Applied, Rejected, Interviewing, Offer, ...). Decoupled from FitVerdict/push_to_tracker entirely — see docs/adr/0014-update-status-tool.md.

push_to_tracker and update_status both verify the target job_id actually belongs to your configured Notion database before writing to it — see docs/adr/0016-job-id-ownership-check.md.

  • list_applications(status=None) — lists tracked jobs from the configured TrackingStore, each with job_id plus whatever tool-populated fields your schema declares (typically status, fit_rating, notes). Pass status to filter to an exact (case-sensitive) match, e.g. "Not yet applied" — useful for questions like "what am I waiting to hear back on" without opening Notion.

Resume text is split into light, section/role-sized chunks before embedding (src/job_search_mcp/chunking.py) rather than embedded whole — see docs/adr/0008-resume-chunking-strategy.md.

Tracking field schema

push_to_tracker and list_applications don't hardcode a field list — they read tracking_schema.yaml (path overridable via TRACKING_SCHEMA_PATH, gitignored like .env; copy tracking_schema.example.yaml to get started). Each field is declared as either:

  • manual: true — the tool never reads or writes it (company, comp range, source, work arrangement, ...). Documentation only.

  • derived_from: <status_fixed | fit_rating_from_bucket | key_notes> — a tool-populated field, computed from a FitVerdict. These three are the only values a FitVerdict can currently be turned into; the schema says which of them your tracker wants and under what property/column name, not how to compute them.

A tool-populated field also needs a backend location: notion.property/notion.type for Notion, sqlite.column for SQLiteTrackingStore (which only tracks fields that declare a sqlite.column at all — it has no concept of Notion's manual fields).

Someone with a simpler tracker than the author's just lists fewer fields. A misconfigured individual field (an unrecognized derived_from, or a notion.property that doesn't exist on the live database) is warned about and skipped rather than failing the whole write; only an unreadable or structurally invalid schema file itself is a hard failure. See docs/adr/0011-configurable-tracking-field-schema.md for the full design.

Setup

  1. just install

  2. Copy .env.example to .env and fill in your Qdrant and Notion connection details.

  3. Copy tracking_schema.example.yaml to tracking_schema.yaml and edit the field list to match your own tracker (see Tracking field schema, above).

  4. Ingest a resume: uv run python -m job_search_mcp.ingest path/to/resume.pdf

  5. Register with Claude Code (project-scoped):

    claude mcp add job-search-mcp -- uv run --directory "$(pwd)" job-search-mcp
  6. In a Claude Code session in this project, ask it to call match_job with a real job description, then push_to_tracker against a row you already track in Notion.

Roadmap

Shipped: match_job (retrieval), push_to_tracker, list_applications, find_or_create_application, and update_status against NotionTrackingStore, the YAML-configurable tracking field schema (docs/adr/0011-configurable-tracking-field-schema.md), and the ingestion pipeline (ResumeSource → chunking → embedding → VectorStore).

Planned next:

  • evaluate_fit — move fit-bucket judgment (the rubric in docs/job_fit_scoring_algorithm.md) inside the server via an internal LLM call, so it no longer depends on the calling assistant applying the rubric itself (docs/adr/0009-caller-agnostic-reversal.md, docs/adr/0010-layer-split-design-evaluate-fit.md)

  • Wire SQLiteTrackingStore up as a selectable backend (it exists, reads the same tracking schema, and is tested, but the server currently always constructs NotionTrackingStore)

  • Google Drive-backed ResumeSource implementation

  • Revisit the embeddings choice if local sentence-transformers quality proves insufficient (docs/adr/0006-embeddings-choice-open.md)

Deferred, low priority (no design commitment beyond the note below):

  • Bulk re-evaluation when candidate_profile.yaml changestarget_floor/title_mapping_note are documented as editable-but-stable, so a value changing after postings are already tracked is a foreseeable case. Open question: whether list_applications + re-running evaluate_fit is sufficient once that tool exists, or whether the original job_description text needs to be persisted somewhere (it currently isn't) to make re-evaluation possible later.

  • Duplicate/near-duplicate JD detection — companies commonly post near-identical reqs for genuinely different underlying roles (same title, similar boilerplate, different team/contract). Neither match_job/evaluate_fit nor find_or_create_application (docs/adr/0013-find-or-create-application.md, which only matches exact company+role text) currently detect this. Direction: a lightweight hash/fuzzy-match check against previously-ingested postings, surfaced as a warning rather than blocking.

Both items above were raised alongside specific anecdotes (a mid-search target_floor change; two near-duplicate "Skylight" postings) that a transcript-search check found no independent record of — see docs/adr/0015-prompt-injection-defense-for-job-description-text.md's Verification section for the same check applied to a related claim from the same source. Both items stand on the general reasoning above regardless of that.

Development

just install   # uv sync
just test      # uv run pytest (unit tests only; integration needs QDRANT_URL)
just lint      # uv run ruff check .
just run       # uv run job-search-mcp (stdio MCP server)

CI (.github/workflows/ci.yml) runs lint and the unit test suite on every push to main and every pull request.

Available Tools

3 tools
list_applicationsA

List tracked jobs from the configured tracking store (Notion).

Args: status: If given, only return rows whose Status matches exactly (e.g. "Not yet applied", "Applied"). Case-sensitive — must match the tracking store's status value exactly. Omit to return every tracked row.

Each entry has job_id plus whatever tool-populated fields your tracking_schema.yaml declares (see docs/adr/0011-configurable-tracking-field-schema.md) — typically status, fit_rating, and notes. Statuses beyond "Not yet applied" (Applied, Recruiter screen, ...) are manual edits made in Notion, not something this server drives.

ParametersJSON Schema
NameRequiredDescriptionDefault
statusNo

Output Schema

ParametersJSON Schema
NameRequiredDescription
resultYes

TDQS

A4/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full burden. It discloses that status matching is case-sensitive and exact, that returned fields depend on a configurable schema, and clarifies which statuses are server-driven versus manual edits. It explains what gets returned (job_id plus tool-populated fields) and the read-only nature of the operation, providing good behavioral context beyond the minimal schema.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is front-loaded with the core purpose in the first sentence, then parameter details, then return-value context. It's reasonably compact for the information conveyed, though the return-fields paragraph is slightly verbose with the documentation reference. No wasted sentences; each paragraph earns its place.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a single-optional-parameter read tool with no annotations, the description covers purpose, filter semantics, return shape (with reference to a configurable schema), and data provenance. An output schema exists to document return structure. It's complete enough for an agent to invoke confidently, though it could note error cases or pagination behavior.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must carry all parameter meaning. It thoroughly explains the 'status' parameter: exact-match semantics, case-sensitivity, example values, and behavior when omitted. The key insight that filtering requires exact tracking-store values is genuinely useful and not derivable from the bare schema.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose4/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the tool 'lists tracked jobs from the configured tracking store (Notion)', specifying the verb (list), resource (tracked jobs), and source (Notion tracking store). It distinguishes from siblings by clarifying it reads tracked applications rather than matching or pushing jobs, though it doesn't explicitly name the sibling alternatives.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description provides clear usage context: the optional 'status' filter for exact matches, instructions to omit it to return all rows, and a warning that certain statuses are manual edits made in Notion rather than server-driven. It gives practical when-to-use guidance via the filter semantics, though it doesn't explicitly state when to use a sibling tool instead.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

match_jobA

Retrieve resume/experience evidence relevant to a job description.

Args: job_description: The full text of the job description to match against. source_url: Optional URL the job description was pulled from, for reference.

Returns a heuristic retrieval_score (top-match cosine similarity) and the retrieved resume chunks with their individual similarity scores. Does not synthesize strengths/gaps/notes — reason over the retrieved evidence yourself, applying the job-fit://rubric resource linked in this result.

ParametersJSON Schema
NameRequiredDescriptionDefault
source_urlNo
job_descriptionYes

Output Schema

ParametersJSON Schema
NameRequiredDescription
source_urlYes
job_descriptionYes
retrieval_scoreYes
retrieved_chunksYes

TDQS

A4.1/5.0
Behavior4/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

No annotations are provided, so the description carries the full burden of behavioral disclosure. It does disclose a key behavioral trait: the tool returns raw retrieval scores and resumes chunks WITHOUT synthesizing strengths/gaps/notes, explicitly telling the agent to reason over the evidence itself. This is valuable transparency about the tool's computational behavior (heuristic cosine similarity) and its non-analytical nature. It doesn't cover auth/permissions or failure modes, but for a retrieval-style tool the core disclosure is present and helpful.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is well-structured with clear sections: purpose sentence, Args list, and Returns paragraph. It's appropriately detailed for a retrieval tool and front-loads the purpose. There is a minor dependency on the job-fit://rubric resource which isn't fully elaborated, and the Args/Returns formatting is somewhat technical, but overall it's concise with no wasted sentences.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

With only 2 parameters (1 required), no enums, and an output schema present, the tool is relatively simple. The description explains what the tool returns (retrieval_score, chunks, similarities) and, crucially, what it does NOT do (synthesize strengths/gaps/notes), directing the agent to the job-fit://rubric. The main gap is that the rubric resource is referenced vaguely without stating what it contains, but the output schema and low parameter count reduce the burden on the description.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters3/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

The schema description coverage is 0%, meaning the schema provides only type names (job_description, source_url) with titles but no descriptive help. The description compensates meaningfully: it explains job_description is 'the full text of the job description to match against' and source_url is 'optional URL the job description was pulled from, for reference.' However, it adds no format details for job_description (e.g., expected length, formatting) or when source_url might matter. This is adequate compensation for low coverage but not exhaustive.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description opens with a specific verb+resource statement: 'Retrieve resume/experience evidence relevant to a job description.' This clearly distinguishes it from its siblings (push_to_tracker, list_applications), which are evident from context to be write/listing operations. The purpose is unambiguous and specifically tied to retrieval matching.

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines4/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description clearly implies when to use this tool: when you need evidence retrieval against a job description for fit analysis. It explicitly states what the tool does NOT do ('Does not synthesize strengths/gaps/notes') and instructs the agent to reason over retrieved evidence applying the job-fit://rubric resource. This is strong guidance about how to use the output. However, it doesn't explicitly contrast with sibling tools (e.g., when NOT to use this in favor of list_applications), which would push it to a 5.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

push_to_trackerA

Write an evaluate_fit result to the configured tracking store (Notion).

Args: job_id: The Notion page ID of the job you already track. Updates that existing row in place — never creates a new row. verdict: The full evaluate_fit output (see docs/evaluate_fit_schema.md). dry_run: If true, returns the mapped Notion properties payload without writing anything, so you can review it first.

Only the fields your tracking_schema.yaml marks tool-populated are touched — every other, manual field on the row (company, comp range, source, work arrangement, etc.) is left as-is. See docs/adr/0011-configurable-tracking-field-schema.md.

A misconfigured tool-populated field (an unrecognized derived_from, or — on a real write, not dry_run — a notion.property that doesn't exist on your database) is skipped rather than failing the whole write; check warnings in the result. dry_run's warnings only cover what can be checked without a Notion API call, since dry_run makes none — a bad property name only shows up in warnings on a real write.

ParametersJSON Schema
NameRequiredDescriptionDefault
job_idYes
dry_runNo
verdictYes

TDQS

A4.7/5.0
Behavior5/5

Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?

With no annotations provided, the description carries the full behavioral burden and it delivers extensively. It discloses that only tool-populated fields (per tracking_schema.yaml) are touched while manual fields are left intact, that misconfigured fields are skipped rather than failing the whole write, that warnings appear in the result, and that dry_run makes no Notion API call so its warning coverage is limited. This is exceptionally transparent about side effects and failure modes.

Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.

Conciseness4/5

Is the description appropriately sized, front-loaded, and free of redundancy?

The description is dense and well-organized with Args, behavior, and edge-case handling clearly sectioned. It's longer than minimal but every sentence adds operational value — dry_run semantics, field-preservation behavior, and warning behavior are all genuinely useful. Slightly long but front-loaded with the core operation first.

Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.

Completeness4/5

Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?

For a write tool with no annotations and no output schema, the description is thorough: it covers the write target, in-place update semantics, field-selection behavior driven by tracking_schema.yaml, error handling for misconfigured fields, and dry_run's limitations. It would benefit from describing the result structure (what warnings/properties are returned) since there's no output schema, but it's largely complete for effective use.

Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.

Parameters4/5

Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?

Schema description coverage is 0%, so the description must compensate, and it does. It explains job_id (Notion page ID of an existing tracked job), verdict (full evaluate_fit output, referencing docs), and dry_run (preview mapped payload without writing). The only minor gap: it references external docs (evaluate_fit_schema.md, adr/0011) rather than describing the verdict structure inline, though reasonable given complexity.

Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.

Purpose5/5

Does the description clearly state what the tool does and how it differs from similar tools?

The description clearly states the verb ('Write'), the resource ('an evaluate_fit result to the configured tracking store (Notion)'), and the key behavior — updating an existing row in place, never creating a new one. This distinguishes it effectively from sibling tools like match_job (which produces the fit result) and list_applications (which reads applications).

Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.

Usage Guidelines5/5

Does the description explain when to use this tool, when not to, or what alternatives exist?

The description explicitly explains when to use this tool (after evaluate_fit produces a result, to persist it) and gives critical usage context: job_id must be an already-tracked Notion page ID, whether to use dry_run to preview mapping before writing, and how misconfigured fields are handled. It distinguishes from siblings by positioning this as the write step following the match/evaluate step.

Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

  1. 3 tool updatesv0.1.0
    • First observedlist_applications
    • First observedmatch_job
    • First observedpush_to_tracker

TDQS

A4.1/5.0

Scored across 3 tools

Disambiguation4/5

The three tools have clearly distinct purposes: match_job retrieves evidence, push_to_tracker writes results, and list_applications reads tracked jobs. There's no real overlap between them. The only minor confusion is that push_to_tracker's write and list_applications's read both touch the same Notion store, but their actions are opposite and clear.

Naming Consistency4/5

All three names follow a clear verb_noun pattern: match_job, push_to_tracker, list_applications. Each uses an imperative verb followed by the target. Minor inconsistency: 'push_to_tracker' is a multi-word noun phrase while 'match_job' and 'list_applications' are more compact, but the pattern is consistent enough.

Tool Count4/5

Three tools is on the low side but reasonable for a focused job-search workflow. The surface covers matching, tracking, and listing, which are the core operations. One could argue a remove/cleanup tool is missing, but the scope of an MCP for job search is narrow enough that 3 tools feels appropriately scoped, not thin.

Completeness3/5

The server covers the core workflow: match a job, push the evaluation to the tracker, and list tracked applications. However, there are notable gaps — no way to create a new job row (push_to_tracker explicitly requires an existing job_id and never creates a row), no update/retract on previous evaluations, and no removal of stale entries. The create-to-track action is a significant missing lifecycle step for a job-search tracking workflow.

Maintenance

ActivitySlowing
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    A
    maintenance
    An MCP server that exposes a perpetual, honest job-application pipeline as typed tools an LLM agent can call, with fit scoring, verified resume building, and a submission planner enforced by code, not prompts.
    16
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    A local-first, open-source MCP server that analyzes jobs, matches your CV, tailors documents, and tracks applications — all on your machine with no data uploaded.
    AGPL 3.0
  • A
    license
    Not graded
    quality
    C
    maintenance
    An MCP server that exposes job-search and application-management capabilities to compatible AI clients, enabling discovery of vacancies, drafting of tailored application materials, and coordinated human-approved submissions.
    MIT
  • A
    license
    Not graded
    quality
    C
    maintenance
    MCP server that aggregates and deduplicates job listings from multiple public sources, ranks them against a user's resume, and exposes tools for searching, viewing details, explaining fit, and tracking applications.
    MIT