Skip to main content
Glama
mzterwalexzyy

resume-fit-scanner

Resume/Job-Fit Scanner

A single, stateless Agentic Service Provider (ASP) tool for the OKX.AI Genesis Hackathon: analyze_resume_fit compares a resume against a target job description and returns a structured ATS fit report. Nothing else -- no resume generation, no chat, no crypto logic.

Try it live: https://app.145-241-206-88.sslip.io -- a public demo site (paste text or upload a real PDF/DOCX/TXT resume) that calls the exact same core.analyze/core.file_extract code the deployed MCP server runs. The ASP itself is registered on-chain as Agent #4956 on X Layer.

What it does

Input: job_description_text (plain pasted job posting text, required), plus the resume as either:

  • resume_text -- plain pasted text, or

  • resume_file_base64 + resume_file_type -- a base64-encoded PDF, DOCX, or TXT file (max 5MB), for callers that want to upload a file instead of pasting.

Provide exactly one of the two resume forms; see core/file_extract.py for the PDF/DOCX -> text conversion (deterministic, no LLM involved).

Output (JSON):

{
  "fit_score": 79,
  "missing_keywords": ["flask", "database design", "graphql", "kubernetes"],
  "formatting_issues": [],
  "suggestions": ["Add a specific, quantified bullet showing your experience with \"flask\" -- ..."],
  "summary": "Your resume matches 79% of this role's key requirements -- here's how to close the gap."
}

If the input is empty, too short, or doesn't look like resume/job-description text, it returns {"rejected": true, "reason": "..."} instead of a score.

Related MCP server: Analyse-CV

How the score is actually computed (what's real, what's not)

Everything that produces fit_score, missing_keywords, and formatting_issues is plain deterministic code, not an LLM call. Given the same two input strings, you get the exact same output every time. The pipeline, in order:

  1. core/extract.py -- pulls candidate requirements out of the job description. Two mechanisms, both rule-based:

    • a curated ~200-term skills/tools/soft-skills taxonomy (core/skills_taxonomy.py), matched by word boundary;

    • a handful of regex patterns over bullet lines and signal phrases ("experience with X", "knowledge of Y") to catch requirement phrases the taxonomy doesn't already list. Terms found under a "Requirements"/"Qualifications" header are weighted 2x; terms under "Preferred"/"Nice to have" are weighted 1x.

  2. core/match.py -- checks each requirement's presence in the resume text (word-boundary match, plus a small synonym table for things like js/javascript, k8s/kubernetes). fit_score is the weighted percentage of requirements found present. This is the only place the number is computed -- nothing downstream can change it.

  3. core/formatting.py -- rule-based checks for ATS-breaking patterns in plain text: no standard section headers, no dates near an experience section, pipe/tab/column layouts (table proxies), 300+ character unbroken lines (text-box proxies), and icon/dingbat glyphs.

  4. core/phrasing.py -- this is the only step that touches an LLM, and only for wording. It takes the already-computed missing keywords, formatting fixes, and score, and either:

    • renders them through fixed English templates (default, fully offline, what the test harness uses), or

    • or asks an LLM to rephrase the same facts more naturally, via whichever provider has a key set (checked in order: ANTHROPIC_API_KEY, then NVIDIA_API_KEY via NVIDIA's NIM API, then OPENROUTER_API_KEY) -- either way, it verifies the response still contains the exact score and every named missing keyword before using it, silently falling back to the template otherwise. Provider choice never changes what gets checked.

The model never invents the score or the gap list; it can only reword facts that were already decided by steps 1-3.

Known limitation: whether the resume arrives as pasted text or an uploaded PDF/DOCX, core/formatting.py's checks all run on the resulting plain text -- so "formatting issues" are detected via textual proxies (pipe characters, long unbroken lines, missing headers/dates, icon glyphs) rather than by inspecting the original file's actual tables, text boxes, or fonts directly. _extract_docx does pull text out of real Word tables (so it isn't silently dropped -- see tests/test_file_extract.py), but by the time check_table_like_layout runs, a table only shows up as flattened " | "-joined text, the same textual proxy a pasted table would produce. This is the practical ceiling given the analysis runs on text, not a shortcut taken to save time.

Known limitation: matching is literal, not semantic. A requirement only counts as present if the exact term (or a listed synonym, e.g. JS/JavaScript) appears in the resume text -- a resume that says "cross-functional collaboration with the sales team" gets no credit against a job description requiring "communication skills," even though a human reader would. This is the same trade-off the rest of the pipeline makes: an LLM "grading" the match holistically could catch that nuance, but wouldn't give you a reproducible, auditable score. It also means a real mismatch (a marketing resume against a data-scientist JD, say) can legitimately score very low or even 0% -- that's not a bug, it's an honest reflection of zero literal keyword overlap, for exactly the same reason many real ATS keyword scanners would flag it too.

Project layout

core/
  skills_taxonomy.py   curated term list + synonyms (no LLM)
  extract.py           JD -> weighted requirement list (no LLM, injection-filtered)
  match.py             requirements vs resume -> matched/missing/fit_score (no LLM)
  formatting.py        ATS structural issue checks (no LLM)
  phrasing.py          facts -> plain English (LLM optional, verified)
  file_extract.py      PDF/DOCX/TXT -> plain text (no LLM)
  analyze.py           input validation + orchestrates the above
mcp_server/
  billing_stub.py      marked integration point for OKX.AI pay-per-call billing (not implemented)
  server.py            thin MCP tool wrapper around core.analyze + core.file_extract
demo/
  site.py              public landing page + live demo (calls core.analyze directly, not MCP)
  webapp.py            minimal local-only test form, for quick dev iteration
  live_check.py        proves the deployed MCP endpoint works, as a real MCP client
tests/
  samples.py           synthetic, clearly-fake resume/JD pairs (incl. a prompt-injection attempt
                        and a real-world zero-extractable-requirements regression case)
  test_analyze.py      end-to-end assertions against those pairs
  test_file_extract.py PDF/DOCX/TXT upload path, incl. a synthetic table-based DOCX

core/ has no dependency on mcp_server/ -- it's a plain Python function (analyze_resume_fit(resume_text, job_description_text) -> dict) that any transport can wrap without restructuring.

Running the test harness

cd resume-fit-scanner
py -m tests.test_analyze     # or: python -m tests.test_analyze

Runs three synthetic cases and asserts on the output:

  • strong_match -- a backend-engineer resume against a matching JD; expects a high score (currently ~79%) with a handful of missing nice-to-haves (Flask, GraphQL, Kubernetes).

  • weak_match_with_formatting_issues -- a marketing resume against a senior data-scientist JD, deliberately written with a pipe-table skills block, no dates, contact-icon glyphs, and a wall-of-text bullet; expects a low score and all four formatting-issue types to fire.

  • invalid_input -- gibberish, non-resume/non-JD text; expects a rejection object, not a fabricated score.

No API key is required to run this -- core/phrasing.py falls back to templates whenever none of ANTHROPIC_API_KEY, NVIDIA_API_KEY, or OPENROUTER_API_KEY are set.

MCP server wrapper

mcp_server/server.py wraps core.analyze.analyze_resume_fit as a tool named analyze_resume_fit using the official mcp Python SDK's FastMCP helper (the standard Model Context Protocol tool-server shape), plus a ping tool for health checks.

On OKX.AI's specific integration format: this project does not have reliable documented detail on any OKX.AI-specific ASP listing schema beyond "MCP/A2A protocols, paid in USDT, on X Layer." What's built here is a standard MCP tool server, since that's the protocol OKX.AI names for discovery/invocation -- it is not a guess at an OKX-specific manifest format, request signature, or registration payload. If OKX.AI's listing process needs something beyond a standard MCP tool definition, that piece still needs to be confirmed against their actual docs/onboarding flow.

Running locally

pip install -r requirements.txt
py -m mcp_server.server

server.py runs the FastMCP server over streamable-http (bound to 0.0.0.0:$PORT, default 8000) -- not stdio -- because OKX.AI's ASP registration requires a real https:// endpoint it can call, not a local stdio pipe. For a one-off local/stdio smoke test instead (e.g. from a Python REPL), call mcp.call_tool(...) directly as in the checks used during development, or override the transport in mcp.run(...).

Live deployment

Currently deployed at https://resume-fit.145-241-206-88.sslip.io/mcp (a small Oracle Cloud "Always Free" Ubuntu VM). Stack:

  • resume-fit-scanner.service (systemd) -- runs python -m mcp_server.server under the repo's venv, Restart=on-failure, listens internally on 0.0.0.0:8000.

  • Caddy reverse-proxies 443/80 -> localhost:8000 and auto-provisions a real Let's Encrypt certificate. The hostname uses sslip.io (resume-fit.<dashed-ip>.sslip.io always resolves to <ip>) so no domain purchase was needed -- Let's Encrypt still issues a normal trusted cert for it via HTTP-01/TLS-ALPN-01.

  • Both OCI's cloud-level Security List (VCN-level firewall) and the instance's local iptables had to separately allow inbound 80/443 -- either one alone blocks Let's Encrypt's validation servers with a same-symptom "timeout during connect" error, so if this ever needs redeploying elsewhere, check both layers.

The public demo site (demo/site.py) runs alongside it on the same box as its own resume-fit-site.service, listening internally on 0.0.0.0:8080 and reverse-proxied by the same Caddy instance at https://app.145-241-206-88.sslip.io (a second sslip.io hostname on the same IP, with its own auto-provisioned Let's Encrypt cert). It calls core.analyze/core.file_extract directly rather than going through MCP -- it's a presentation layer for humans, not part of the ASP tool itself.

Environment variables

Variable

Required

Purpose

PORT

no

Port the MCP streamable-http server binds to internally. Default 8000.

SITE_PORT

no

Port the public demo site (demo/site.py) binds to internally. Default 8080.

ANTHROPIC_API_KEY

no

Enables LLM-phrased suggestions/summary via Claude (see above). Checked first. Omit to run fully offline on templates.

NVIDIA_API_KEY

no

Alternative via NVIDIA's NIM API, checked second (only used if ANTHROPIC_API_KEY is unset).

NVIDIA_MODEL

no

NVIDIA NIM model ID. Default z-ai/glm-5.2.

OPENROUTER_API_KEY

no

Alternative via OpenRouter, checked last (only used if neither of the above is set).

OPENROUTER_MODEL

no

OpenRouter model ID. Default nousresearch/hermes-3-llama-3.1-405b:free.

Payment / billing: x402 challenge implemented, verification/settlement not

OKX's ASP review flagged the endpoint returning HTTP 406 instead of the required 402 for an unauthenticated call to the paid tool -- their docs state paid A2MCP endpoints "must support x402" (a payment-required HTTP challenge/response scheme). mcp_server/x402_middleware.py fixes exactly that: an unauthenticated tools/call for analyze_resume_fit now gets a properly-shaped HTTP 402 response (PAYMENT-REQUIRED header, base64 JSON body with scheme: "exact", X Layer network eip155:196, the community-recognized USD₮0 contract, this ASP's registered wallet as payTo, and the registered 0.1 USDT fee in atomic units). It runs as a raw ASGI middleware in front of FastMCP's own app -- see the module docstring for why not Starlette's BaseHTTPMiddleware (a body-replay/receive-queue conflict on this Starlette version).

First round of OKX's review turned up a second bug: the replay after a successful payment was still getting re-challenged with another 402. Root cause was the middleware only recognizing Authorization / X-PAYMENT headers, but the correct replay header for this PAYMENT-REQUIRED (accepts-based v2) challenge shape is PAYMENT-SIGNATURE -- confirmed from OKX's own onchainos-skills docs (references/accepts-schemes.md: "Replay = resend the original request with <header_name>: <authorization_header> (here PAYMENT-SIGNATURE)"), not something this project guessed at. PAYMENT_HEADER_NAMES in the middleware now checks all three (payment-signature, x-payment, authorization).

Second round turned up a field/scheme mismatch: the challenge used scheme: "exact" together with maxAmountRequired, but that field belongs to the "upto" scheme (a variable cap), not "exact" (a fixed price). Since this ASP charges a fixed 0.1 USDT/call, "exact" is the correct scheme -- it now carries amount instead.

Third round: the payment replay itself was getting HTTP 406. OKX's payment-replay client sends Accept: application/json only (no text/event-stream); by default, the underlying mcp SDK's POST handler requires the Accept header to carry both (see mcp/server/streamable_http.py's _validate_accept_header in the installed SDK -- read directly rather than guessed at). Fixed with json_response=True on the FastMCP(...) constructor in mcp_server/server.py: a first-class SDK setting, not a workaround, that relaxes the Accept requirement to application/json alone and returns a single JSON response instead of requiring an SSE stream. Verified with a raw HTTP client (bypassing any MCP client library's own header handling) reproducing OKX's exact scenario end-to-end -- see tests/test_mcp_accept_header.py, which exercises the real composed app (FastMCP.streamable_http_app() + X402Middleware), not just the middleware in isolation.

Fourth round: a fully session-less replay still failed. With (3) fixed, a real MCP client (initialize -> tools/call, carrying whatever session ID the server returns) worked. But a pay-per-call replay client has no reason to maintain persistent MCP session state across the payment lifecycle -- and a request with no prior initialize and no Mcp-Session-Id got HTTP 400 "Missing session ID" instead of 406, a different failure with the same practical effect: no result retrievable. Fixed with stateless_http=True on the same FastMCP(...) constructor -- per the installed SDK's own streamable_http_manager.py docstring, this "creates a completely fresh transport for each request with no session tracking," so no session ID is required. Side effect worth knowing: a real client's initialize response no longer carries a session ID at all (nothing to track), so any client that assumes one exists needs to tolerate None. Both the original Accept-header fix and this one are exercised together in tests/test_mcp_accept_header.py, including the exact "no prior handshake at all" case.

What's deliberately still not implemented: cryptographic verification of a presented payment proof, and on-chain settlement. Any request carrying any of those three headers is let through unverified today. Building real verification + settlement means this server holding gas funds and broadcasting transactions on its own -- financial-transaction code that deserves its own deliberate scope, not a silent add-on to a formatting fix. mcp_server/billing_stub.py's verify_payment() (called inside the tool handler, after the middleware's gate) is where that would wire in.

What stays free, so a real MCP client can still discover the tool before paying: the initialize/tools/list handshake, and the ping health-check tool. Everything else to the MCP path -- including a bare GET/POST with no recognizable MCP shape (what a compliance probe sends) -- requires a payment-shaped header. See tests/test_x402_middleware.py for the full behavior matrix, exercised against the middleware directly.

Prompt-injection guardrails

This tool is meant to be called by arbitrary agents/bots on a marketplace, and its JSON output (missing_keywords, suggestions) is the kind of thing a calling agent often feeds straight into its own next prompt. That makes a hostile job_description_text a realistic reflected prompt-injection vector even when no LLM is involved on our side at all -- an attacker only needs their injected phrase to survive extraction and come back out verbatim in the response for a careless downstream agent to treat it as an instruction rather than data.

Defenses, in the order data actually flows:

  1. core/extract.py -- every regex-derived candidate phrase (the only extraction path that touches attacker-controlled text; the curated taxonomy list is our own fixed data) is checked against _is_safe_candidate_phrase: a pattern list for common injection framing ("ignore all previous instructions", "system:", "you are now", "reveal your system prompt", etc.), a ban on structurally suspicious characters (<>{} ` and newlines), and a 60-char length cap. Anything that matches never becomes a missing_keyword in the first place, so it can't leak into the output regardless of whether the optional LLM step below runs.

  2. core/phrasing.py -- the one place attacker-derived text actually reaches an LLM (only when ANTHROPIC_API_KEY is set). The prompt explicitly frames every interpolated item as untrusted data to reword, never to obey, mirroring the same "render as-is, ignore embedded instructions" pattern OKX's own okx-ai skill uses for untrusted agent-to-agent fields. The response is then re-checked against the same pattern/character filter from step 1 before being trusted, falling back to the deterministic template on any hit.

  3. Blast radius is architecturally limited regardless: phrase_output() is a leaf call -- nothing downstream executes code or takes a further action based on its return value, so a successful injection's worst case is a wrong sentence in the response, not a compromised process.

tests/samples.py's prompt_injection_attempt pair and the matching assertions in tests/test_analyze.py exercise this directly: several injection payloads embedded in a job description (fake "ignore previous instructions", "reveal your system prompt", etc.) are asserted to never appear anywhere in the tool's JSON output.

Privacy

  • Stateless: no resume or job-description text is written to disk, logged, or cached anywhere in this codebase. Each call only ever sees the two strings passed to it.

  • No name, contact info, or identifying data is requested. If a pasted resume happens to contain a name/email/phone (normal for resumes), it's neither stripped nor used for anything beyond the presence/absence checks above -- it's never echoed back or repurposed.

  • No financial data, government IDs, or other sensitive personal data categories are requested or processed.

A
license - permissive license
-
quality - not tested
B
maintenance

Maintenance

Maintainers
Response time
Release cycle
Releases (12mo)
Commit activity

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

View all related MCP servers

Related MCP Connectors

  • Tailor resumes, generate cover letters, render CVs as PDF, and browse 22+ templates.

  • Search 6.3M+ live jobs from companies' own career pages, plus resume tailoring & cover letters.

  • Search a live index of millions of open jobs from employer career sites and 100+ ATS platforms.

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mzterwalexzyy/resume-fit-scanner'

If you have feedback or need assistance with the MCP directory API, please join our Discord server