Skip to main content
Glama
satovarb16
by satovarb16

Runway

Release Python License: MIT

Runway is a memory for your job hunt. You paste a job description into the conversation; Claude scores it, tailors a resume, and Runway remembers which jobs you applied to, what state each one is in, and — the part that matters most — which resume version you sent for each job. Ask "did I apply to Datadog?" months later and get back "yes, and here's the exact resume you sent."

Quick install

/plugin marketplace add satovarb16/runway
/plugin install runway@satovarb

Claude Code wires up the MCP server for you — no JSON to edit.

Updating: new releases arrive through the normal plugin flow — no need to touch PyPI.

/plugin marketplace update satovarb
/plugin update runway@satovarb

Then run /reload-plugins (or restart Claude Code) to load the new version. The plugin pins an exact release version, so updating it pulls the matching server release.

You will not be told a new version exists. Claude Code can update plugins on its own and announce it — Plugins updated: runway — but by default that only happens for marketplaces on its built-in allowlist, which covers Anthropic's own and nothing else. Every third-party marketplace, this one included, stays on whatever version you installed until you run the two commands above. No banner, no prompt, no notice.

To get the announcement instead of the silence, turn auto-update on once:

/plugin

Pick the satovarb marketplace, then Enable auto-update. Claude Code then refreshes this marketplace and its installed plugins for you, and tells you when it did.

That switch lives on your machine, not in this repo — a plugin author cannot enable it for you, which is exactly why it is written down here.

Option B: manual .mcp.json

Create a .mcp.json file in the directory where you run Claude Code:

{
  "mcpServers": {
    "runway": {
      "command": "uvx",
      "args": ["--from", "runway-mcp==0.4.0", "runway-mcp"]
    }
  }
}

That's it. Open Claude Code — uvx downloads and runs the server automatically.

Don't have uv? Install it: pip install uv (or see uv docs)

Alternative: install from source

git clone https://github.com/satovarb16/runway
cd runway
pip install -e ".[dev]"

Then use python -m server in place of the uvx command and its --from arguments in your .mcp.json, and add "cwd": "/path/to/runway".

Related MCP server: h1b-mcp

Why Runway doesn't fetch job postings, and never checked visa sponsorship this way

Earlier versions of this server fetched job postings from Greenhouse/Ashby/Lever (fetch_job_posting) and checked H-1B sponsorship history against USCIS data (check_visa_sponsorship). Both tools, along with the read-only get_profile migration hatch, are removed as of this release — not deprecated, deleted.

The fetch/scrape approach was fragile by construction: every job board changes its markup on its own schedule (and increasingly blocks headless scraping outright), so a server that parses HTML is permanently one layout change — or one anti-bot update — away from silently returning garbage. Claude, on the other hand, already reads the job posting you paste into the conversation. It does not need the server to fetch a second copy of the same text over HTTP. The server's job is to persist and shape data, not to scrape it.

The USCIS H-1B sponsorship lookup went with it. It's replaced by something smaller and more honest: set_work_authorization lets you declare, once, the countries you may legally work in, and analyze_job compares each job's country against that declaration live, on every call — never a stored, staleness-prone verdict. See Work authorization below.

Step 0 (required): save your resume

Do this once before anything else. analyze_job needs a stored resume — without one it returns a no_resume error asking you to save one first.

You: "Here's my CV, save it as my general resume: /path/to/resume.pdf"

Claude reads your CV, drafts the resume as plain text, and saves it with save_resume_version (see Tools below for the full reference). Resume versions are raw text, versioned, and append-only — nothing is ever overwritten or deleted. The first version you save (parent_id=None) establishes the base of the tree. Later, when you decide to apply somewhere, Claude saves a version tailored for that job pointing back at an existing one (parent_id=<some version's id>, job_id=<the id save_job_analysis returned for that job>) without touching it — analyze_job never scores a job against a resume already rewritten for that same job, since that would just be scoring the resume against itself.

Your "general" resume is the most recent version with no job_id — not necessarily the first one. Those are the same thing until you update your CV, and different afterwards.

Updated your CV? Save it as a new version with parent_id set to any existing version's id and no job_id. It becomes your general resume by being the most recent untailored one. Passing parent_id=None a second time is rejected — there is only ever one root.

Step 0b (required): declare your work authorization

analyze_job also needs to know where you're allowed to work — without it, it returns a no_work_authorization error asking you to declare this first.

You: "I can legally work in the United States and Germany."
Claude: set_work_authorization(countries=["United States", "Germany"])

This replaces any prior declaration — it's a statement about the whole set, not an addition. From then on, every analyze_job call compares that job's country against your current declaration live (never cached, never stored on the job record) and returns one of three outcomes in work_authorization.status:

  • "authorized" — the job's country matches a declared one. No warning.

  • "warned" — it doesn't match. work_authorization.warning names both the job's country and your declared list, exactly as you and the job posting stated them, so a false warning caused by an unrecognized spelling is self-diagnosing.

  • "undetermined" — the job's country couldn't be read at all (empty, or something like "..."). Never silently treated as authorized or as a mismatch.

The warning is advisory only — it never blocks analyze_job from returning a result.

Runway never fetches a job posting. Paste the description into the conversation, and Claude extracts what it needs:

You: "Evaluate this for me — [paste the full job description]"
Claude:
  1. Extracts title, company, and country from the pasted text
  2. analyze_job(title=..., company=..., country=..., url=<link, if you have one>)
     → your general resume + a scoring guide + a work-authorization check
  3. Scores the match against the posting text already in this conversation
     → APPLY / CONSIDER / SKIP + reasoning
  4. If APPLY or CONSIDER: tailors your resume and saves it
     → save_job_analysis(..., jd_text=<the full posting>) returns an id
     → save_resume_version(..., job_id=<that id>)

url is optional. If the posting has no URL (a referral, a screenshot, a DM), give it a custom_title instead — a short, memorable label like "Acme referral role". You need to remember that title, because unlike a URL it isn't unique: two jobs can share the same custom_title, and a later lookup by title alone can be ambiguous (get_job or analyze_job will refuse to guess and ask you to specify an id instead). Claude will remind you of this the first time you save a job with no URL.

The job's full pasted text (jd_text) is stored only if you pass it to save_job_analysisanalyze_job itself never sees or stores it, and a job you analyze but never save leaves no trace.

Tracking applications

You: "I applied to that Datadog role, sent the one-page version."
Claude: set_application_status(id=<job id>, status="applied",
                               notes="sent the one-page version")

You: "Did I apply to Datadog?"
Claude: list_jobs(company="Datadog")
        → get_job(id=<the match>) → linked resume version summaries
        → get_resume_version(id=<that version>) → the exact text you sent

Application status is one of 7 values: not_applied, applied, interviewing, offer, rejected, withdrawn, ghosted. Transitions are deliberately unvalidated — any status can move to any other (a reopened process is real), and list_jobs(status=...) takes either a single value or a list, so "what's currently in progress" (applied, interviewing, offer) is one call, not three.

Two kinds of notes, and why they're separate

Each job carries two note fields, written by different tools:

  • notes — the analysis. Why the score is what it is, what matched, what's missing. Written by save_job_analysis, and nothing else ever touches it.

  • status_notes — the application timeline. set_application_status appends one dated line per update and never replaces what's already there.

notes         Score 78. Matched: Python, agents, production evidence.
              Missing: Docker, AWS. The wall is the 3–5 years.

status_notes  [2026-09-17] applied — sent the one-page version
              [2026-09-24] interviewing — recruiter screen, 30 min
              [2026-10-02] rejected — call, no reason given

These were one field until 0.4.0, and the collision was not theoretical: recording "applied on the 17th" overwrote the analysis, so the highest-scoring jobs in a store were exactly the ones whose reasoning had been destroyed. Splitting them makes that structurally impossible instead of a rule you have to remember. To revise the analysis itself, call save_job_analysis with the job's id — omitted fields keep their previous values.

Deleting a job

delete_job(id=...) is for a record that should never have existed — a mistyped entry, a duplicate, leftover test data. It is not the same as the withdrawn status, which says you pulled out of a live process: that's a real event worth keeping, and treating the two as interchangeable poisons every later query.

The job and its captured posting are deleted. Tailored resume versions are kept, with their job_id set to NULL — a resume you actually sent outlives the posting it was aimed at, so the append-only tree stays intact and only the pointer dies. There's no undo, so the result is a receipt: it names the job and every resume version that was unlinked.

Storage and migration

All data lives in one local SQLite database at ~/.config/runway-mcp/runway.db. If you're upgrading from a pre-0.3.0 install with jobs.json/resumes.json, the first tool call after upgrading migrates both files into the database automatically — a .bak copy of each original is written first, and the JSON files themselves are left untouched. Nothing to run, nothing to configure.

Schema upgrades are automatic, additive, and run once. When a release adds something to the database schema, the first tool call after upgrading applies it to your existing file inside a single transaction and stamps the new schema version, so it never runs twice. Upgrades only ever add — nothing is dropped, rewritten, or reordered, and your rows are never touched. Going the other way is refused rather than attempted: a database written by a newer runway-mcp than the one you're running raises an error telling you to upgrade the package, because the file is fine and silently rewriting it to an older shape is not.

If you have an even older, pre-0.2.0 ~/.config/runway-mcp/profile.json (a structured profile from before resume versioning existed), it is not migrated automatically — the tool that used to read it back out, and the whole structured-profile system it belonged to, are gone in this release with no replacement. Read the file yourself (or ask Claude to), and save its content as your general resume:

You: "Here's my old profile.json content — save it as my general resume text."
Claude: save_resume_version(content=<rewritten as resume text>, label="General", parent_id=None)

The server never reads, writes, or deletes profile.json — delete it yourself once you've migrated by hand.

How it works

Claude Code launches this server over stdio and calls its tools when relevant. You don't invoke the tools directly — Claude decides when to call them based on the conversation.

The tools persist and shape data; Claude does the reasoning. The server never calls back to the model (no MCP sampling), so it works on any MCP host — including Claude Code, which does not support sampling. Claude drafts and tailors your resume text itself, scores the job-vs-resume match using the rubric analyze_job returns, and decides what to save; the server only persists what Claude gives it.

Status

Tool

Status

analyze_job

Working — loads your general resume, a scoring guide, and a live work-authorization check

save_job_analysis

Working — persists an analyzed job (upserts by id, then by url)

get_job

Working — retrieves one job by id/url/custom_title, with linked resume version summaries

list_jobs

Working — lists stored jobs, filterable by company, status, score, date

set_application_status

Working — sets a stored job's application status and appends to its timeline

delete_job

Working — permanently deletes a job by id/url, keeping tailored resume versions

save_resume_version

Working — saves a resume version (raw text, append-only)

get_resume_version

Working — retrieves a resume version by id or "latest"

list_resume_versions

Working — lists saved resume versions, newest first

set_work_authorization

Working — declares the countries you may legally work in

Tools

analyze_job(title: str, company: str, country: str, url: str | None = None, custom_title: str | None = None) -> AnalyzeJobResult

Read-only — writes nothing. Loads your general resume and a scoring guide so Claude can score the match against the job posting text already in this conversation (the server never fetches it). Also runs a live work-authorization comparison for country against your current declaration.

Preconditions, checked in order:

  • error="no_resume" — no usable general resume exists yet. Run save_resume_version first.

  • error="no_work_authorization" — you haven't called set_work_authorization yet.

  • error="corrupt" — the store exists but couldn't be read. Distinct from the two above: telling you to run the tool that writes to the same broken file wouldn't help.

  • error="ambiguous_custom_title" — more than one saved job shares the custom_title you passed; re-analyze with url instead, or use get_job with a specific id first.

On success, returns:

{
  "extracted": { "title": "...", "company": "...", "country": "...", "url": null, "custom_title": "Acme referral role" },
  "resume": { "id": "...", "label": "...", "content": "...", "parent_id": null, "job_id": null, "created_at": "..." },
  "scoring_guide": { "instructions": "...", "recommendation_rules": ["SKIP if the match score is below 40.", "..."] },
  "work_authorization": { "status": "warned", "warning": "This job's country ('Germany') is not among your declared work-authorized countries (United States)." },
  "notice": null
}

Recommendation rules (Claude applies these from scoring_guide):

  • SKIP if the match score is below 40.

  • APPLY if the match score is 70 or higher.

  • CONSIDER in every other case.

The general resume is selected so it was never written for the job being analyzed: the most recently saved version with no job_id, or — if every version is tailored to some job — the most recent root version, excluding any tailored to the job you're analyzing now.

save_job_analysis(title: str, company: str, country: str, id: str | None = None, url: str | None = None, custom_title: str | None = None, jd_text: str | None = None, score: int | None = None, recommendation: str | None = None, notes: str | None = None) -> SaveJobResult

Persists an analyzed job record. At least one of url or custom_title is required — that's how you (or Claude) find the record again later. Resolution order: id given → updates that exact record (error="not_found" if unknown); no id but url matches an existing record → upsert by URL, unchanged semantics from prior releases (an omitted optional argument leaves the existing value in place, so a bare re-save never wipes your score/notes); otherwise → a new record. Never matches by title — editing a title for clarity never silently creates or merges a duplicate.

Setting a url that another job already has returns error="duplicate_url"; neither record changes. Saving without a url returns a message reminding you the custom_title is now the only handle to this record — worth surfacing to the user, since they'll need to recall it.

get_job(id: str | None = None, url: str | None = None, custom_title: str | None = None, include_description: bool = False) -> GetJobResult

Retrieves one job by exactly one of id, url, or custom_title. has_description is always present so you can tell a description exists without paying to load it; pass include_description=True to get the full pasted text back in description. Also returns the linked resume version summaries (no content) — this is the headline query: "did I apply to X?" → "yes, and here's the resume." Fetch the actual text with get_resume_version.

Because custom_title isn't unique, matching more than one job returns error="ambiguous" naming every matching id, rather than silently picking one. Unknown id/url/custom_title → error="not_found".

list_jobs(since: str | None = None, status: str | list[str] | None = None, min_score: int | None = None, company: str | None = None, limit: int | None = None, sort_by: str = "analyzed_at") -> ListJobsResult

Lists stored jobs, filtered → sorted → limited. company is a case-insensitive substring match — this is the headline query, "did I apply to Acme?" status takes one status or a list of statuses, so "what have I applied to" (applied, interviewing, offer, and whatever else you consider active) is a single call. sort_by is "analyzed_at" (default, newest first) or "score" (descending, unscored jobs last). Job records never include the full pasted description (jd_text) — only get_job(include_description=True) returns it.

set_application_status(status: str, id: str | None = None, url: str | None = None, notes: str | None = None) -> SetStatusResult

Sets a stored job's application status to one of the 7 values: not_applied, applied, interviewing, offer, rejected, withdrawn, ghosted. Transitions are deliberately unvalidated — any status can move to any other (e.g. rejectedinterviewing succeeds), because reopened hiring processes are real and the server doesn't get to say otherwise. Prefer id over url — it always resolves, even for jobs saved without a URL. Returns error="not_found" for an unknown id/url, error="invalid_status" for an unrecognized value (record left unchanged).

notes appends one dated line to the job's status_notes timeline — [YYYY-MM-DD] applied — sent the one-page version — and never touches notes, which holds the analysis. Omit it and the timeline is left alone; only the status changes. These were a single field until the two purposes collided in practice: recording "applied on the 17th" wiped the reasoning behind the score, so the highest-scoring jobs in the store were exactly the ones whose analysis was gone. To edit the analysis, call save_job_analysis with an id — omitted fields keep their previous values.

delete_job(id: str | None = None, url: str | None = None) -> DeleteJobResult

Permanently deletes a stored job. For a record that should never have existed — a mistyped entry, a posting that turned out to be a duplicate, leftover test data. This is not the same as the withdrawn status, which says "I pulled out of this process": that's a real event worth keeping, and conflating the two poisons every later query.

Lookup is by id or url only — deliberately not custom_title, which get_job does accept. custom_title isn't unique, and deleting by an ambiguous key is precisely how the wrong record gets destroyed.

What happens to everything pointing at the job:

  • The captured job description is deleted — it belongs to the job and means nothing without it.

  • Tailored resume versions are kept, with job_id set to NULL. A resume you actually sent outlives the posting it was aimed at, so the append-only tree stays intact; only the pointer dies. This is why the append-only UPDATE trigger guards every column except job_id.

There is no confirmation flag and no undo. The result is a receipt — it names the job and every resume version that was unlinked, so you can see exactly what disappeared. Returns error="invalid_input" when neither key is given, error="not_found" for an unknown id/url.

save_resume_version(content: str, label: str, parent_id: str | None = None, job_id: str | None = None) -> SaveResumeVersionResult

Saves a new resume version as raw text. Append-only — no version is ever mutated or deleted (enforced by the database, not just application discipline), so your history is always intact. The store enforces a single-root tree:

  • The first version you ever save must have parent_id=None — this establishes your general resume.

  • Every version after that must set parent_id to an existing version's id. Passing parent_id=None again (or an unknown id) is rejected.

  • job_id is optional — set it when a version is tailored for a specific job. It must reference a job that already exists: call save_job_analysis first and pass the id it returns. An unknown job_id returns error="job_not_found", not a raw database error.

Returns error="invalid_parent" (empty store expects parent_id=None, non-empty store requires it) or error="parent_not_found" (unknown parent_id) on failure — no version is written in either case.

get_resume_version(id: str) -> GetResumeVersionResult

Retrieves one resume version by its exact id, or the most recently created version via id="latest" (not necessarily the general one — if the newest version is tailored to some job, "latest" returns that). Returns error="not_found" if no such version exists.

list_resume_versions(job_id: str | None = None, limit: int | None = None) -> ListResumeVersionsResult

Lists saved resume versions newest first, as summaries — no content field, so listing 20 versions doesn't dump 20 full resumes into context. Call get_resume_version once you know which id you want. Filter to versions tailored for one job with job_id (the id save_job_analysis returned for that job).

set_work_authorization(countries: list[str]) -> SetWorkAuthorizationResult

Declares the full list of countries you may legally work in — replaces any prior declaration, it does not add to it. Pass an empty list to explicitly declare you're authorized nowhere (distinct from never having called this tool at all — analyze_job treats the two differently). Country names are free text; the server canonicalizes common spellings ("USA", "United States", "U.S." all match) but never rejects an unrecognized one — it echoes back both the raw text you gave and the canonical form it stored, so a misread is visible immediately rather than causing a silent false warning later.

Tool vs. reasoning boundary

These tools only persist and shape data. Claude handles all reasoning:

  • Scoring the match between the resume and the pasted posting

  • Interpreting the work-authorization warning in context

  • Whether the role is a good fit overall

This is intentional — tools that encode judgment make Claude less useful, not more.

Tests

pytest -m contract      # fast contract tests
pytest -m integration   # server tool registration
pytest                  # full suite

Contributing

pip install -e ".[dev]"
pre-commit install       # runs ruff lint + format before every commit

PRs welcome.

Releasing

Publishing is driven by a tag. Bump the version in all six places listed in RELEASING.mdpyproject.toml, manifest.json (both its version and its --from pin), the plugin's plugin.json, the plugin's .mcp.json pin, and the manual-install snippet above. Pins are runway-mcp==X.Y.Z, resolved from PyPI. test_every_version_site_agrees_with_pyproject fails the build if any of them drift. Then push the tag from the release branch, and merge only once the publish job is green:

git tag vX.Y.Z
git push origin vX.Y.Z

.github/workflows/release.yml takes it from there: it checks that the tag names the same version as pyproject.toml — the remaining five sites are checked against pyproject.toml by test_every_version_site_agrees_with_pyproject in the same run, so the two together pin all six — then runs lint and the full test suite on Python 3.11/3.12/3.13, builds the sdist and wheel, runs twine check, and only then uploads. Merge to master once that upload is done — the marketplace serves master, so a pin that lands there before PyPI has the version breaks every install until the upload catches up.

Everything that can fail runs before the upload on purpose — PyPI never lets a version number be reused, even after a delete, so a bad publish cannot be undone, only superseded.

The upload is currently switched off. The publish job is gated on the repository variable PYPI_PUBLISH_ENABLED, because the Trusted Publishing publisher below was never configured and every tag died at the upload with invalid-publisher. A tag today runs the full verify job and then skips the upload rather than failing it. Set that variable to "true" once the publisher exists — it needs no code change. Meanwhile the pins point at the git tag, so releases work; PyPI just stays at 0.1.2.

One-time setup. The workflow authenticates with PyPI Trusted Publishing rather than an API token, so there is no long-lived secret in the repo to leak or rotate. GitHub signs a short-lived token per run and PyPI verifies the signature.

runway-mcp already exists on PyPI, so this is a publisher on an existing project — not a pending publisher, which is the separate flow for a name that has never been published. Go to https://pypi.org/manage/project/runway-mcp/settings/publishing/, choose GitHub under Add a new publisher, and fill in exactly:

Field

Value

Owner

satovarb16

Repository name

runway

Workflow name

release.yml

Environment name

pypi

All four must match or PyPI rejects the token — the environment name in particular, since it is what scopes the trust to the gated job rather than to any workflow in the repo.

On the GitHub side, the pypi environment is created automatically the first time the workflow runs. Create it yourself under Settings → Environments if you want to add required reviewers first, which gates the upload behind a manual approval — worth doing, given that a published version can never be reused.

License

MIT © satovarb

Available Tools

10 tools
analyze_jobA

Gather the general resume + scoring guide so Claude can score the match.

  1. Verifies a general resume exists (returns error envelope if not).

  2. Verifies work authorization has been declared at least once (returns error="no_work_authorization" if not — see design D7/module docstring for the precondition ordering decision).

  3. Returns the general resume, a scoring guide, an extracted echo of the caller's input, and a live work-authorization comparison.

The match score and APPLY/CONSIDER/SKIP recommendation are NOT computed by this tool — after calling it, score the candidate's resume against the job (already pasted into this conversation) and apply the scoring_guide's recommendation_rules in your reply.

The resume injected here is the GENERAL resume (design D6), not the most recently tailored one: scoring a job against a resume already tailored FOR that job would inflate the match score by scoring the resume against itself.

This tool NEVER raises for any documented failure mode — all such failures are encoded in the return envelope. An unexpected keyword argument (e.g. a JD text payload) is a caller programming error and raises normally, exactly as calling any Python function with an unknown keyword does.

Args: title: Job title, Claude-extracted from the pasted posting. company: Company name, Claude-extracted. country: Free-text country, Claude-extracted. Compared against the user's declared work authorization; a mismatch populates work_authorization with an advisory warning. url: The job posting URL, if any. Resolved to a job_id (exact match against the jobs table) so the general-resume selection can exclude a resume already tailored to this job. None when the user has no URL to give (e.g. a referral). custom_title: The user's handle for a URL-less job. Echoed back, AND — when url is absent — resolved to a job_id the same way url is, so Guard 1 (anti-self-scoring) stays armed for jobs saved without a url. If more than one saved job shares this custom_title, this tool refuses to guess and returns error="ambiguous_custom_title".

Returns: AnalyzeJobResult with extracted/resume/scoring_guide/ work_authorization populated on success, or error/message fields populated on failure ("no_resume" | "corrupt" | "ambiguous_custom_title" | "no_work_authorization").

ParametersJSON Schema
NameRequiredDescriptionDefault
urlNo
titleYes
companyYes
countryYes
custom_titleNo

Output Schema

ParametersJSON Schema
NameRequiredDescription
errorNo
noticeNo
resumeNo
messageNo
extractedNo
scoring_guideNo
work_authorizationNo

TDQS

A4.7/5.0
Behavior5/5

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

With no annotations, the description carries the full load and does so richly: it declares the tool never raises for documented failures (all encoded in the return envelope), names every failure mode, explains the ambiguous_custom_title refusal-to-guess behavior, and justifies the anti-self-scoring guard. This is exactly the behavioral context an agent cannot get from structured fields.

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?

Front-loaded with the core purpose and a numbered breakdown, and most sentences earn their place given the zero annotation and zero schema coverage. It is somewhat verbose with internal design references (D6/D7, module docstring) that add little for a calling agent.

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

Completeness5/5

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

Given an output schema exists, return values need not be explained, yet the description also covers the success/failure envelope shape. Combined with full parameter and behavioral coverage, an agent has everything needed to call it correctly.

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

Parameters5/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: all five parameters are documented, including non-obvious semantics like country being compared against work authorization and url/custom_title both being resolved to a job_id to keep the anti-self-scoring guard armed.

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?

It states a specific composite action (gather the general resume + scoring guide) and, crucially, disambiguates the name by declaring the actual scoring is NOT done here — the caller scores afterward. An agent can distinguish it from save_job_analysis or get_job purely from the text.

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?

It clearly frames this as the first step and tells the caller what to do next ('after calling it, score the candidate's resume... apply the scoring_guide's recommendation_rules'). It also documents the precondition chain (resume must exist, work authorization must be declared). It does not explicitly name sibling tools or state when to prefer them, so it falls short of 5.

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

delete_jobA

Permanently delete a stored job record, by id or url.

For a record that should never have existed: a mistyped entry, a posting that turned out to be a duplicate, test data. This is NOT the same thing as the withdrawn status, which says "I pulled out of this process" — a real event worth keeping. Conflating the two poisons every later query, because a withdrawn job still counts as a job you looked at.

Lookup is by id or url ONLY — deliberately not custom_title, which get_job does accept. custom_title is not unique (save_job_analysis never matches on it), and deleting by an ambiguous key is precisely how the wrong record gets destroyed. Ambiguity is survivable on a read; here it is not.

What happens to everything pointing at the job:

  • job_descriptions row: deleted. The captured posting belongs to the job and has no meaning without it.

  • resume_versions: NOT deleted — job_id is set to NULL. The tree is append-only and stays that way: a tailored resume is a document you really sent, and it survives the posting it was aimed at. Only the pointer dies, which is why the append-only UPDATE trigger guards every column EXCEPT job_id.

There is no confirmation flag and no undo. The returned receipt names the job and every resume version that was unlinked, so the caller can report exactly what disappeared — with no delete_job there is no way back, the receipt is the safety mechanism.

This tool NEVER raises.

Args: id: Job id (preferred — always resolvable, unlike url). url: Alternate lookup key when id is not known.

Returns: DeleteJobResult with success=True and the receipt on success; success=False with error="invalid_input" (neither key given), "not_found", "corrupt", or "write_error".

ParametersJSON Schema
NameRequiredDescriptionDefault
idNo
urlNo

Output Schema

ParametersJSON Schema
NameRequiredDescription
idNo
urlNo
errorNo
titleNo
companyNo
messageNo
successYes
deleted_descriptionNo
unlinked_resume_versionsNo

TDQS

A5/5.0
Behavior5/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. It discloses permanence (no undo, no confirmation flag), cascading effects (job_descriptions deleted, resume_versions unlinked with job_id set to NULL), the append-only trigger nuance, that it never raises, and the exact return structure with error codes. This is exceptionally transparent.

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

Conciseness5/5

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

The description is long but every sentence serves a purpose. It is front-loaded with the core action, then usage, then behavioral details, then parameters and return. Bulleted lists and clear section breaks make it scannable. The length is justified by the complexity of the delete operation and its cascading effects.

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

Completeness5/5

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

Given the tool's destructive nature, the description covers everything an agent needs: purpose, when to use, what gets deleted vs. unlinked, no-undo warning, the receipt as a safety mechanism, and all possible error returns. It also distinguishes from siblings and the `withdrawn` status, making it complete for safe invocation.

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

Parameters5/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. It explains both parameters: id is 'preferred — always resolvable, unlike url' and url is 'Alternate lookup key when id is not known.' It also explains why custom_title is not a valid parameter, adding crucial semantic context beyond the bare schema.

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 states a specific verb and resource ('Permanently delete a stored job record, by id or url') and explicitly differentiates itself from the `withdrawn` status and from get_job, which accepts custom_title. This makes the purpose unambiguous and distinguishes it from siblings.

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?

It clearly defines when to use the tool ('For a record that should never have existed: a mistyped entry, a posting that turned out to be a duplicate, test data'), explicitly contrasts with the `withdrawn` status, and explains why custom_title is deliberately excluded. It also notes that it is not the same as get_job, giving the agent clear routing guidance.

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

get_jobA

Retrieve a single job record by id, url, or custom_title (D6, the REQUIRED read path for jd_text — without this tool, jd_text is write-only).

custom_title exists as a lookup key because it exists as a SAVE affordance: a job saved without a url is findable ONLY by the custom_title the user agreed to (analyze.py's _NO_URL_NOTICE promises exactly this). Without this parameter, that promise had no retrieval path at all — a caller could only dump every job via list_jobs and eyeball it.

custom_title is NOT unique (save_job_analysis deliberately never matches an existing record by it — R3/SC-08), so a lookup CAN match more than one job. Silently returning the first match would hide that ambiguity from the caller and could resolve to the wrong job. Instead: 0 matches -> not_found; exactly 1 -> that job; more than 1 -> error="ambiguous", naming every matching id so the caller can retry with a specific id.

has_description is ALWAYS present in the response, regardless of include_description — it is the affordance that makes the jd_text opt-in discoverable without dragging JD text into context by default. Also returns the linked resume version SUMMARIES (no content) — the headline query is "did I apply to X?" -> "yes, and with this resume." (SC-22). The full text still comes from get_resume_version.

This tool NEVER raises.

Args: id: Job id. Exactly one of id/url/custom_title must be given. url: Alternate lookup key (url is UNIQUE). custom_title: Alternate lookup key for a URL-less job. NOT unique — see above for the multi-match rule. include_description: When True and a description was captured, populates description with the full pasted JD text. Defaults to False so a routine status check does not drag JD text into context.

Returns: GetJobResult with success=True, job, description, has_description, resume_versions on success; success=False with error="not_found" | "invalid_input" | "corrupt" | "ambiguous" on failure.

ParametersJSON Schema
NameRequiredDescriptionDefault
idNo
urlNo
custom_titleNo
include_descriptionNo

Output Schema

ParametersJSON Schema
NameRequiredDescription
jobNo
errorNo
messageNo
successYes
descriptionNo
has_descriptionNo
resume_versionsNo

TDQS

A5/5.0
Behavior5/5

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

With no annotations provided, the description fully carries the behavioral burden. It discloses that the tool NEVER raises, specifies exact outcomes for 0/1/many matches, explains the ambiguous error behavior, and notes that has_description is always present. This is exemplary transparency.

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

Conciseness5/5

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

The description is long but front-loaded with the core purpose, and each subsequent paragraph earns its place by explaining non-obvious behavior or hazards. There is no filler or redundant restatement of the schema.

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

Completeness5/5

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

Given the tool's moderate complexity and the absence of annotations, the description is complete: it covers lookup keys, matching semantics, error values, default behavior, return contents, and integration with sibling tools. The output schema can supply the remaining structural field details.

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

Parameters5/5

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

Schema description coverage is 0%, but the description compensates thoroughly. It explains the meaning and uniqueness of each lookup key, the 'exactly one' constraint, the multi-match risk of custom_title, and the default behavior of include_description. This goes far beyond the bare schema.

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 first sentence states a specific verb, resource, and scope: 'Retrieve a single job record by id, url, or custom_title.' This clearly distinguishes it from list_jobs and other siblings and immediately establishes the tool's lookup keys.

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 positions the tool as the 'REQUIRED read path for jd_text,' explains when id versus custom_title is appropriate, and contrasts with list_jobs for dumping records. It also names get_resume_version as the source for full resume text, giving alternatives and conditions.

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

get_resume_versionA

Retrieve a single resume version by id, or the most recent via "latest".

This tool NEVER raises.

Args: id: Exact version id, or the literal string "latest" for the most recently created version in the store.

Returns: GetResumeVersionResult with success=True and version on success; success=False with error="not_found" or error="corrupt".

ParametersJSON Schema
NameRequiredDescriptionDefault
idYes

Output Schema

ParametersJSON Schema
NameRequiredDescription
errorNo
messageNo
successYes
versionNo

TDQS

A4.4/5.0
Behavior4/5

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

With no annotations, the description carries the burden and does disclose meaningful behavior: it 'NEVER raises' and enumerates failure modes (error="not_found" or "corrupt"). It omits auth/permission requirements and any rate-limit or store-scope caveats, so it is strong but not exhaustive.

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

Conciseness5/5

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

Front-loaded with the core action in the first sentence, followed by terse Args/Returns blocks; every line adds information and there is no filler.

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

Completeness5/5

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

An output schema exists, so the Returns block is supplementary rather than required, and its error enumeration plus the never-raises guarantee give the agent everything needed to call and interpret results for a one-parameter lookup.

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

Parameters5/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 defines id as either an exact version id or the magic literal "latest" for the most recently created version in the store. That is precisely the semantic an agent cannot infer from a bare string parameter.

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?

States a specific verb (Retrieve) and resource (a single resume version) with clear scope, and the word 'single' implicitly distinguishes it from the sibling list_resume_versions. An agent can identify the operation without opening the schema.

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

Usage Guidelines3/5

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

It explains the two modes of use (exact id vs the literal "latest") which helps an agent pick the right argument, but never states when to prefer this over list_resume_versions or get_job, nor any prerequisites for calling it.

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

list_jobsA

Return stored job records with optional filtering, sorting, and limiting.

Pipeline order: FILTER -> SORT -> LIMIT, expressed as WHERE/ORDER BY/ LIMIT (D9). Arguments are validated in Python BEFORE any SQL is built, so an invalid status still produces the "use one of: ..." message rather than a raw SQL error. This tool NEVER raises.

Args: since: ISO-8601 string cutoff (inclusive). status: One ApplicationStatus value, or a list to match any member. min_score: Minimum score threshold (inclusive). None scores excluded. company: Substring match against company, case-insensitive (lower() on both sides, ASCII-only — SQLite's lower() does not fold non-ASCII). This is SC-1, the release's headline query ("did I apply to Acme?") — D9. limit: Maximum number of records to return (after sort). sort_by: "analyzed_at" (default, newest first) or "score" (descending, None scores last).

Returns: ListJobsResult with success=True and filtered/sorted records on success; success=False with error_message on failure.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNo
sinceNo
statusNo
companyNo
sort_byNoanalyzed_at
min_scoreNo

Output Schema

ParametersJSON Schema
NameRequiredDescription
jobsNo
countNo
successYes
error_messageNo

TDQS

A4.1/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 does so thoroughly. It discloses that the tool NEVER raises, validates arguments before building SQL, communicates validation failures through error_message, preserves pipeline order, and handles edge cases like None scores and ASCII-only case folding.

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-organized: a one-line summary, pipeline/validation context, detailed Args, and Returns. Some internal markers like 'SC-1' and 'D9' add minor noise, but every substantive sentence contributes to correct invocation.

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

Completeness5/5

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

Given six optional parameters, zero annotations, and no schema-level descriptions, this description is complete enough for correct use. It explains filtering, sorting, limiting, validation, failure behavior, and default ordering; the output schema covers the return shape.

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

Parameters5/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 document all parameters, and it does. Each of the six parameters gets meaningful detail: inclusive cutoffs, list-or-single status, None exclusions, case-insensitive substring matching, limit after sort, and sort_by valid values with defaults.

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 opening sentence 'Return stored job records with optional filtering, sorting, and limiting' clearly identifies an action and resource. The plural 'records' implies this is a listing tool rather than a single-record fetcher like get_job, but it does not explicitly name or differentiate from sibling tools.

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

Usage Guidelines2/5

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

There is no explicit guidance on when to use this tool versus alternatives such as get_job or other job-related tools. The description focuses on mechanics and semantics rather than usage context or exclusions, leaving the agent to infer when this is the right choice.

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

list_resume_versionsA

Return stored resume versions, newest-first, with optional filtering.

Pipeline order: FILTER -> SORT -> LIMIT. This tool NEVER raises.

Args: job_id: When provided, only versions linked to this exact job id are returned. limit: Maximum number of records to return (after sort).

Returns: ListResumeVersionsResult with success=True and filtered/sorted SUMMARIES (no resume text); success=False with error_message.

ParametersJSON Schema
NameRequiredDescriptionDefault
limitNo
job_idNo

Output Schema

ParametersJSON Schema
NameRequiredDescription
countNo
successYes
versionsNo
error_messageNo

TDQS

A4.1/5.0
Behavior4/5

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

With no annotations, the description carries the full behavioral burden, and it does well: it declares ordering, the FILTER -> SORT -> LIMIT pipeline, that the tool NEVER raises, and that results contain summaries rather than resume text. It stops short of noting auth/permission requirements, but the error-free contract and payload shape are disclosed.

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?

Front-loaded with the core behavior, then a compact pipeline note and Args/Returns blocks. The Args/Returns headings add a little structural overhead but every sentence carries information; nothing is wasted.

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 two-parameter, read-only list tool with an output schema present, this covers ordering, filtering, pipeline order, failure contract, and payload granularity. The return-format sentence slightly duplicates the output schema, but no agent-critical detail is missing.

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 clarifies that job_id is an exact-match link filter and that limit is applied after sorting. This meaningfully exceeds the bare 'integer/string' types in the schema, though it doesn't state limit's default or bounds.

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?

States a specific verb and resource ('Return stored resume versions'), plus the default ordering (newest-first) and the optional-filter scope. An agent can immediately distinguish it from get_resume_version (single item) and save_resume_version (write) without opening any schema.

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

Usage Guidelines3/5

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

It explains the one filter condition ('job_id: when provided, only versions linked to this exact job id are returned'), which implies when the parameter is useful, but it never states when to prefer this tool over get_resume_version or how it relates to list_jobs. Usage is implied rather than directed.

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

save_job_analysisA

Persist an analyzed job record. Upserts by id then by url (D10).

Resolution order:

  1. id given -> that row is the target; unknown id -> error="not_found".

  2. No id, url given and matching an existing row -> that row is the target (upsert by URL, unchanged semantics from the prior JSON store, including "omitted optional argument preserves the previous value").

  3. Otherwise -> a brand new row. title/company are NEVER used to match an existing record (R3) — a user editing a title for clarity must not silently create a duplicate under an update path, and title text is never treated as an update key either way.

url and custom_title follow the same "explicit argument overwrites, omission (None) preserves the previous value" rule as score/ recommendation/notes/jd_text — this is what lets a URL be supplied later without disturbing the custom_title, and vice versa (SC-04). On a brand new row, at least one of url/custom_title must resolve to a non-None value, or the record has no way for a human to find it again (R1, SC-03).

This tool NEVER raises — all failures are encoded in the return envelope.

Args: title: Job title (Claude-extracted). Always required, always overwrites — no omit-preserve semantics. company: Company name (Claude-extracted). Same as title. country: Free-text country (Claude-extracted). Same as title. id: Existing job id to update. None to create or upsert-by-url. url: Job posting URL. Nullable/unique attribute, not the identity (D1). Omitted (None) preserves the existing value on an update. custom_title: User-supplied handle when url is absent. Same omit-preserve rule as url. jd_text: Full pasted job description, stored in the job_descriptions side table. Omitted (None) leaves any existing captured JD untouched. score: 0-100 match score (Claude-supplied). Omit-preserve. recommendation: APPLY/CONSIDER/SKIP (Claude-supplied). Omit-preserve. notes: Free-text notes. Omit-preserve.

Returns: SaveJobResult with success=True, id, url, custom_title, updated flag on success; success=False with error/message on failure.

ParametersJSON Schema
NameRequiredDescriptionDefault
idNo
urlNo
notesNo
scoreNo
titleYes
companyYes
countryYes
jd_textNo
custom_titleNo
recommendationNo

Output Schema

ParametersJSON Schema
NameRequiredDescription
idNo
urlNo
errorNo
messageNo
successYes
updatedNo
custom_titleNo
possible_duplicate_idNo

TDQS

A4.7/5.0
Behavior5/5

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

With no annotations at all, the description carries the full behavioral burden and does so thoroughly: 'This tool NEVER raises — all failures are encoded in the return envelope,' the omit-preserve rule, the 'title/company are NEVER used to match' safeguard against silent duplicates, the R1/SC-03 requirement that a new row resolve url or custom_title, and the unknown-id error='not_found' path. These are exactly the traits an agent cannot infer from the 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?

Well front-loaded (purpose, then resolution order, args, returns) and each sentence carries meaning. It loses a point for repetition of the omit-preserve rule across the body and per-arg list, and for unexplained internal codes (D10, R3, SC-04, R1, SC-03) that are noise to an agent with no key.

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

Completeness5/5

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

Given 10 params, 0% schema coverage, and a complex multi-key upsert, the description covers resolution logic, per-param semantics, the new-row constraint, and failure encoding. An output schema exists, so the brief Returns note is sufficient; nothing needed to call it correctly is missing.

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

Parameters5/5

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

Schema coverage is 0%, so the description must carry parameter meaning — and it documents all 10 args individually, distinguishing always-overwrite fields (title/company/country) from omit-preserve fields (url/custom_title/jd_text/score/recommendation/notes) and explaining id's create-vs-update role. This is a large value add over the bare schema types.

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?

States a specific verb and resource ('Persist an analyzed job record') plus the core mechanism ('Upserts by id then by url'), which cleanly separates it from read siblings like get_job/list_jobs and from analyze_job. An agent can identify the tool's role without opening the schema.

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 three-step 'Resolution order' gives explicit conditions for when an id targets an existing row, when a url upserts, and when a brand new row is created, which is strong invocation guidance. However, it never names an alternative sibling (e.g. use get_job to read, analyze_job to compute), so the tool-vs-tool routing remains implicit.

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

save_resume_versionA

Append a new resume version to the store. Never mutates or deletes.

The store enforces a single-root tree: the first saved version's parent_id MUST be None; every subsequent version's parent_id MUST reference an existing version id. job_id, when given, MUST reference an existing job — call save_job_analysis first and pass the id it returns (the FK is real now, D1/D8). Content is stored raw and verbatim. This tool NEVER raises.

Args: content: Raw resume text, stored verbatim. label: Caller-supplied human-readable label. parent_id: id of the version this one derives from. None only on the very first save (establishes the base). job_id: Optional job id this version was tailored for.

Returns: SaveResumeVersionResult with success=True, id, label, parent_id, job_id on success; success=False with error/message on failure ("invalid_parent", "parent_not_found", "job_not_found", "invalid_input", "corrupt", "write_error").

ParametersJSON Schema
NameRequiredDescriptionDefault
labelYes
job_idNo
contentYes
parent_idNo

Output Schema

ParametersJSON Schema
NameRequiredDescription
idNo
errorNo
labelNo
job_idNo
messageNo
successYes
parent_idNo

TDQS

A4.9/5.0
Behavior5/5

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

With no annotations, the description carries the full burden and does so thoroughly: it discloses the single-root tree invariant, foreign-key enforcement, verbatim raw storage, that it NEVER raises, and enumerates the exact failure codes (invalid_parent, parent_not_found, job_not_found, etc.). This is more behavioral detail than most schemas or annotations provide.

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 key guarantee is front-loaded and the Args/Returns structure is scannable. Slight bloat from internal jargon ('the FK is real now, D1/D8') that adds nothing for an agent deciding how to call it.

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

Completeness5/5

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

For a mutation tool with no annotations, the description covers constraints, prerequisites, success shape, and the full error vocabulary, so an agent has everything needed to invoke and interpret results. Although an output schema exists, restating the result fields is a harmless reinforcement rather than a gap.

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

Parameters5/5

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

Schema coverage is 0%, so the description must compensate, and it documents all four parameters with meaning beyond the bare titles – content is stored verbatim, label is caller-supplied, parent_id's None-only-on-first-save rule, and job_id's FK requirement. The constraint semantics are essential and present.

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 opening sentence states a specific verb and resource ('Append a new resume version to the store') and immediately clarifies the append-only nature ('Never mutates or deletes'). This cleanly separates it from read siblings like get_resume_version and list_resume_versions and from save_job_analysis.

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?

It states precise prerequisites and conditions: the first save's parent_id MUST be None, subsequent saves MUST reference an existing id, and job_id requires calling save_job_analysis first and passing its returned id. The append-only scope tells the agent when this tool is NOT the right one for changes.

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

set_application_statusA

Set the application status of a stored job record, by id or url.

Transitions are deliberately UNVALIDATED — any of the 7 status values may transition to any other. This tool NEVER raises.

notes APPENDS one dated line to status_notes and never touches notes, the analysis field. Those were a single column until the two purposes collided in practice: recording "applied on the 17th" wiped the reasoning behind the score, so the highest-scoring jobs in the store were exactly the ones whose analysis was gone. Splitting them makes that structurally impossible rather than a rule to remember.

To edit the analysis itself, call save_job_analysis with an id — its omit-preserve semantics leave every field you do not pass alone.

Args: status: One of: not_applied, applied, interviewing, offer, rejected, withdrawn, ghosted. id: Job id (preferred — always resolvable, unlike url). url: Alternate lookup key when id is not known. notes: Optional follow-up note, appended to the timeline as "[YYYY-MM-DD] ". Omitted, the timeline is left untouched and only the status changes.

Returns: SetStatusResult with success=True, id, url, status, previous_status and the resulting status_notes on success; success=False with error="invalid_status" (record unchanged), error="not_found", or error/message on a store failure.

ParametersJSON Schema
NameRequiredDescriptionDefault
idNo
urlNo
notesNo
statusYes

Output Schema

ParametersJSON Schema
NameRequiredDescription
idNo
urlNo
errorNo
statusNo
messageNo
successYes
status_notesNo
previous_statusNo

TDQS

A5/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 does so thoroughly: it states transitions are deliberately unvalidated, the tool never raises, notes appends a dated line to status_notes and never touches the analysis field, and it documents all error modes such as invalid_status and not_found. The historical rationale for splitting status_notes from notes adds meaningful context beyond the schema.

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

Conciseness5/5

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

The description is front-loaded with the core action and then layers in behavioral caveats, parameter details, and return values in a logical order. The historical explanation for why notes and status_notes are separate is slightly longer than strictly necessary, but it earns its place by preventing a likely misuse. There is no repetitive or filler content.

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

Completeness5/5

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

For a mutation tool with no annotations, the description is complete: it covers what the tool does, all parameter semantics, exact failure modes, return payload fields, and the relationship to sibling tools like save_job_analysis. An agent has everything needed to decide when to call it and how to interpret the result.

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

Parameters5/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 fully explain the parameters, and it does: status is enumerated with all valid values, id is the preferred lookup key, url is the fallback, and notes specifies its exact append format and behavior when omitted. This goes well beyond what the bare schema properties provide.

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 and resource: 'Set the application status of a stored job record, by id or url.' It also distinguishes itself from save_job_analysis by clarifying that editing analysis is a separate sibling tool's job. The seven allowed statuses are listed explicitly, leaving no ambiguity about what the tool operates on.

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 gives clear when-to-use guidance: use id over url because id is always resolvable, use this tool for status changes, and use save_job_analysis with an id to edit analysis fields. It also explains the note-append behavior versus the analysis field so the agent does not misuse notes. This is explicit routing, not merely implied.

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

set_work_authorizationA

Declare the FULL set of countries the user may legally work in.

REPLACES the previous declaration in its entirety (SC-26) — this is a statement about the whole set ("I can work in X and Y"), not an additive append. An empty list is a valid, distinct declaration (SC-27): "declared, zero countries" differs from "never declared" — analyze_job treats the two differently (no_work_authorization only for the latter).

Countries are canonicalized at write time (tools/_country.py); both the raw text and the canonical form are stored. Two raw spellings that canonicalize to the same country (e.g. "USA" and "United States" in one call) collapse to a single declared row — the first raw spelling encountered wins the echo.

This tool NEVER raises.

Args: countries: Free-text country names, as the user states them. An empty list explicitly declares zero authorized countries.

Entries that canonicalize to nothing ("", " ", "...") are not countries and are dropped. If that leaves nothing, the call is REJECTED rather than silently storing a declaration that means nothing — see the loop below.

Returns: SetWorkAuthorizationResult with success=True and the stored raw/ canonical forms on success; success=False with error="invalid_input" when no given name could be understood, "corrupt" on a broken database, or "write_error" when the write itself fails.

ParametersJSON Schema
NameRequiredDescriptionDefault
countriesYes

Output Schema

ParametersJSON Schema
NameRequiredDescription
errorNo
messageNo
successYes
countries_rawNo
countries_canonicalNo

TDQS

A4.7/5.0
Behavior5/5

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

With no annotations present the description carries the full burden and does so: it discloses the full-replace semantics, that it NEVER raises, the rejection path when all entries canonicalize to nothing, and all three error codes (invalid_input, corrupt, write_error). Canonicalization behavior and the 'first raw spelling wins' echo rule are also spelled out.

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?

Front-loaded with the core contract and structured with explicit Args/Returns sections, but the parenthetical requirement IDs (SC-26, SC-27) and internal module path (tools/_country.py) are implementer-facing noise that a calling agent does not need. Slightly long, but nearly every sentence carries behavioral weight.

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

Completeness5/5

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

For a one-parameter mutation tool with no annotations, the definition covers semantics, edge cases, failure modes, and return shape completely. Although an output schema exists, the description's brief return summary is a bonus rather than a redundancy, and nothing needed to invoke the tool correctly is missing.

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

Parameters5/5

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

Schema coverage is 0% and the single required parameter is fully compensated: free-text country names as the user states them, empty list explicitly declares zero countries, non-country entries like '', ' ', '...' are dropped, and the rejection behavior when nothing remains. An agent knows exactly what to pass and what happens to edge-case input.

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?

States a specific verb+resource ('Declare the FULL set of countries the user may legally work in') with the scope constraint ('FULL set') front-loaded. No sibling tool overlaps this action, so an agent can route to it unambiguously.

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?

Explains that the call replaces the prior declaration rather than appending, that an empty list is a distinct valid declaration, and ties the distinction to how analyze_job interprets it (no_work_authorization only for 'never declared'). This is strong contextual guidance, though it does not frame when to call this versus any other sibling — reasonably so, since no sibling offers an alternative.

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. 4 tool updatesv0.4.0
    • Addeddelete_job
    • Changedget_job1 field changed
      • addedOutput schema / $defs / StoredJob / properties / status_notes
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Status Notes"
        +}
    • Changedlist_jobs1 field changed
      • addedOutput schema / $defs / StoredJob / properties / status_notes
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Status Notes"
        +}
    • Changedset_application_status1 field changed
      • addedOutput schema / properties / status_notes
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Status Notes"
        +}
  2. 14 tool updatesv0.3.1
    • Changedanalyze_job24 fields changed
      • addedInput schema / properties / company
        Added value: +{
        +  "title": "Company",
        +  "type": "string"
        +}
      • addedInput schema / properties / country
        Added value: +{
        +  "title": "Country",
        +  "type": "string"
        +}
      • addedInput schema / properties / custom_title
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Custom Title"
        +}
      • addedInput schema / properties / title
        Added value: +{
        +  "title": "Title",
        +  "type": "string"
        +}
      • addedInput schema / properties / url / anyOf
        Added value: +[
        +  {
        +    "type": "string"
        +  },
        +  {
        +    "type": "null"
        +  }
        +]
      • addedInput schema / properties / url / default
        Added value: +null
      • removedInput schema / properties / url / type
        Removed value: -"string"
      • changedInput schema / required
        Previous value: -[
        -  "url"
        -]New value: +[
        +  "title",
        +  "company",
        +  "country"
        +]
      • removedOutput schema / $defs / EducationEntry
        Removed value: -{
        -  "description": "A single education entry extracted from a CV.",
        -  "properties": {
        -    "degree": {
        -      "anyOf": [
        -        {
        -          "type": "string"
        -        },
        -        {
        -          "type": "null"
        -        }
        -      ],
        -      "default": null,
        -      "title": "Degree"
        -    },
        -    "field": {
        -      "anyOf": [
        -        {
        -          "type": "string"
        -        },
        -        {
        -          "type": "null"
        -        }
        -      ],
        -      "default": null,
        -      "title": "Field"
        -    },
        -    "institution": {
        -      "title": "Institution",
        -      "type": "string"
        -    },
        -    "year": {
        -      "anyOf": [
        -        {
        -          "type": "integer"
        -        },
        -        {
        -          "type": "null"
        -        }
        -      ],
        -      "default": null,
        -      "title": "Year"
        -    }
        -  },
        -  "required": [
        -    "institution"
        -  ],
        -  "title": "EducationEntry",
        -  "type": "object"
        -}
      • removedOutput schema / $defs / ExperienceEntry
        Removed value: -{
        -  "description": "A single work experience entry extracted from a CV.",
        -  "properties": {
        -    "company": {
        -      "title": "Company",
        -      "type": "string"
        -    },
        -    "description": {
        -      "anyOf": [
        -        {
        -          "type": "string"
        -        },
        -        {
        -          "type": "null"
        -        }
        -      ],
        -      "default": null,
        -      "title": "Description"
        -    },
        -    "duration_years": {
        -      "anyOf": [
        -        {
        -          "type": "number"
        -        },
        -        {
        -          "type": "null"
        -        }
        -      ],
        -      "default": null,
        -      "title": "Duration Years"
        -    },
        -    "title": {
        -      "title": "Title",
        -      "type": "string"
        -    }
        -  },
        -  "required": [
        -    "company",
        -    "title"
        -  ],
        -  "title": "ExperienceEntry",
        -  "type": "object"
        -}
      • addedOutput schema / $defs / ExtractedFields
        Added value: +{
        +  "description": "Verbatim echo of analyze_job's Claude-extracted input (design D5).\n\nEvery parser that provided a floor of truth on these fields is deleted;\nthis echo is the ONLY visibility the user gets into a bad extraction. No\nnormalization or reformatting is applied here — that would defeat the\npurpose of showing exactly what was received.",
        +  "properties": {
        +    "company": {
        +      "title": "Company",
        +      "type": "string"
        +    },
        +    "country": {
        +      "title": "Country",
        +      "type": "string"
        +    },
        +    "custom_title": {
        +      "anyOf": [
        +        {
        +          "type": "string"
        +        },
        +        {
        +          "type": "null"
        +        }
        +      ],
        +      "default": null,
        +      "title": "Custom Title"
        +    },
        +    "title": {
        +      "title": "Title",
        +      "type": "string"
        +    },
        +    "url": {
        +      "anyOf": [
        +        {
        +          "type": "string"
        +        },
        +        {
        +          "type": "null"
        +        }
        +      ],
        +      "default": null,
        +      "title": "Url"
        +    }
        +  },
        +  "required": [
        +    "title",
        +    "company",
        +    "country"
        +  ],
        +  "title": "ExtractedFields",
        +  "type": "object"
        +}
      • removedOutput schema / $defs / JobSummary
        Removed value: -{
        -  "properties": {
        -    "company": {
        -      "title": "Company",
        -      "type": "string"
        -    },
        -    "title": {
        -      "title": "Title",
        -      "type": "string"
        -    },
        -    "url": {
        -      "title": "Url",
        -      "type": "string"
        -    }
        -  },
        -  "required": [
        -    "title",
        -    "company",
        -    "url"
        -  ],
        -  "title": "JobSummary",
        -  "type": "object"
        -}
      • removedOutput schema / $defs / ProfileData
        Removed value: -{
        -  "description": "Structured profile data extracted from a CV.\n\nPersonal fields (name, email, location) are stored as flat top-level fields\nrather than nested under a 'personal' object — this matches what Claude\nnaturally returns and simplifies downstream consumers like analyze_job.",
        -  "properties": {
        -    "education": {
        -      "default": [],
        -      "items": {
        -        "$ref": "#/$defs/EducationEntry"
        -      },
        -      "title": "Education",
        -      "type": "array"
        -    },
        -    "email": {
        -      "anyOf": [
        -        {
        -          "type": "string"
        -        },
        -        {
        -          "type": "null"
        -        }
        -      ],
        -      "default": null,
        -      "title": "Email"
        -    },
        -    "experience": {
        -      "default": [],
        -      "items": {
        -        "$ref": "#/$defs/ExperienceEntry"
        -      },
        -      "title": "Experience",
        -      "type": "array"
        -    },
        -    "languages": {
        -      "default": [],
        -      "items": {
        -        "type": "string"
        -      },
        -      "title": "Languages",
        -      "type": "array"
        -    },
        -    "location": {
        -      "anyOf": [
        -        {
        -          "type": "string"
        -        },
        -        {
        -          "type": "null"
        -        }
        -      ],
        -      "default": null,
        -      "title": "Location"
        -    },
        -    "name": {
        -      "anyOf": [
        -        {
        -          "type": "string"
        -        },
        -        {
        -          "type": "null"
        -        }
        -      ],
        -      "default": null,
        -      "title": "Name"
        -    },
        -    "skills": {
        -      "default": [],
        -      "items": {
        -        "type": "string"
        -      },
        -      "title": "Skills",
        -      "type": "array"
        -    },
        -    "summary": {
        -      "default": "",
        -      "title": "Summary",
        -      "type": "string"
        -    }
        -  },
        -  "title": "ProfileData",
        -  "type": "object"
        -}
      • addedOutput schema / $defs / ResumeVersion
        Added value: +{
        +  "additionalProperties": false,
        +  "description": "A single saved resume version. Content is raw text, stored verbatim.",
        +  "properties": {
        +    "content": {
        +      "title": "Content",
        +      "type": "string"
        +    },
        +    "created_at": {
        +      "title": "Created At",
        +      "type": "string"
        +    },
        +    "id": {
        +      "title": "Id",
        +      "type": "string"
        +    },
        +    "job_id": {
        +      "anyOf": [
        +        {
        +          "type": "string"
        +        },
        +        {
        +          "type": "null"
        +        }
        +      ],
        +      "default": null,
        +      "title": "Job Id"
        +    },
        +    "label": {
        +      "title": "Label",
        +      "type": "string"
        +    },
        +    "legacy_job_url": {
        +      "anyOf": [
        +        {
        +          "type": "string"
        +        },
        +        {
        +          "type": "null"
        +        }
        +      ],
        +      "default": null,
        +      "title": "Legacy Job Url"
        +    },
        +    "parent_id": {
        +      "anyOf": [
        +        {
        +          "type": "string"
        +        },
        +        {
        +          "type": "null"
        +        }
        +      ],
        +      "title": "Parent Id"
        +    }
        +  },
        +  "required": [
        +    "id",
        +    "label",
        +    "content",
        +    "parent_id",
        +    "created_at"
        +  ],
        +  "title": "ResumeVersion",
        +  "type": "object"
        +}
      • removedOutput schema / $defs / VisaSummary
        Removed value: -{
        -  "properties": {
        -    "approval_rate": {
        -      "title": "Approval Rate",
        -      "type": "number"
        -    },
        -    "error": {
        -      "anyOf": [
        -        {
        -          "type": "string"
        -        },
        -        {
        -          "type": "null"
        -        }
        -      ],
        -      "default": null,
        -      "title": "Error"
        -    },
        -    "filings": {
        -      "title": "Filings",
        -      "type": "integer"
        -    },
        -    "verdict": {
        -      "title": "Verdict",
        -      "type": "string"
        -    }
        -  },
        -  "required": [
        -    "verdict",
        -    "filings",
        -    "approval_rate"
        -  ],
        -  "title": "VisaSummary",
        -  "type": "object"
        -}
      • addedOutput schema / $defs / WorkAuthorizationCheck
        Added value: +{
        +  "additionalProperties": false,
        +  "description": "Live comparison result embedded in AnalyzeJobResult.work_authorization.\n\nThree outcomes, deliberately distinguishable by `status` alone (D7):\n\"authorized\" (job's country is among the declared ones — no warning),\n\"warned\" (it is not — `warning` names both the job's country and the\ndeclared list, as the user wrote them), \"undetermined\" (the job's\ncountry could not be interpreted at all — never silently treated as\neither of the other two). The warning is always advisory: it never\nblocks the envelope.",
        +  "properties": {
        +    "status": {
        +      "title": "Status",
        +      "type": "string"
        +    },
        +    "warning": {
        +      "anyOf": [
        +        {
        +          "type": "string"
        +        },
        +        {
        +          "type": "null"
        +        }
        +      ],
        +      "default": null,
        +      "title": "Warning"
        +    }
        +  },
        +  "required": [
        +    "status"
        +  ],
        +  "title": "WorkAuthorizationCheck",
        +  "type": "object"
        +}
      • changedOutput schema / description
        Previous value: -"Decision-ready envelope of FACTS. Claude derives the score and verdict.\n\nOn success, job/visa/profile/scoring_guide are populated. The server does\nnot compute a match score or recommendation — those are left to Claude,\nwhich reasons over this envelope and the scoring_guide."New value: +"Decision-ready envelope of FACTS. Claude derives the score and verdict.\n\nOn success, extracted/resume/scoring_guide are populated. The server\ndoes not compute a match score or recommendation — those are left to\nClaude, which reasons over this envelope and the scoring_guide.\n\nNo `job` or `visa` field — both the deleted job-fetch and visa-check\nsteps are gone from this orchestrator entirely."
      • addedOutput schema / properties / extracted
        Added value: +{
        +  "anyOf": [
        +    {
        +      "$ref": "#/$defs/ExtractedFields"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null
        +}
      • removedOutput schema / properties / job
        Removed value: -{
        -  "anyOf": [
        -    {
        -      "$ref": "#/$defs/JobSummary"
        -    },
        -    {
        -      "type": "null"
        -    }
        -  ],
        -  "default": null
        -}
      • addedOutput schema / properties / notice
        Added value: +{
        +  "anyOf": [
        +    {
        +      "type": "string"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null,
        +  "title": "Notice"
        +}
      • removedOutput schema / properties / profile
        Removed value: -{
        -  "anyOf": [
        -    {
        -      "$ref": "#/$defs/ProfileData"
        -    },
        -    {
        -      "type": "null"
        -    }
        -  ],
        -  "default": null
        -}
      • addedOutput schema / properties / resume
        Added value: +{
        +  "anyOf": [
        +    {
        +      "$ref": "#/$defs/ResumeVersion"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null
        +}
      • removedOutput schema / properties / visa
        Removed value: -{
        -  "anyOf": [
        -    {
        -      "$ref": "#/$defs/VisaSummary"
        -    },
        -    {
        -      "type": "null"
        -    }
        -  ],
        -  "default": null
        -}
      • addedOutput schema / properties / work_authorization
        Added value: +{
        +  "anyOf": [
        +    {
        +      "$ref": "#/$defs/WorkAuthorizationCheck"
        +    },
        +    {
        +      "type": "null"
        +    }
        +  ],
        +  "default": null
        +}
    • Removedcheck_visa_sponsorship
    • Removedfetch_job_posting
    • Addedget_job
    • Removedget_profile
    • Addedget_resume_version
    • Addedlist_jobs
    • Addedlist_resume_versions
    • Addedsave_job_analysis
    • Addedsave_resume_version
    • Addedset_application_status
    • Addedset_work_authorization
    • Removedsetup_profile
    • Removedupdate_profile
  3. 6 tool updatesv0.1.2
    • First observedanalyze_job
    • First observedcheck_visa_sponsorship
    • First observedfetch_job_posting
    • First observedget_profile
    • First observedsetup_profile
    • First observedupdate_profile

TDQS

A4.6/5.0

Scored across 10 tools

Disambiguation5/5

Each tool owns a distinct resource-action pair: analyze, save/get/list/delete jobs, status updates, resume version handling, and work authorization. save_job_analysis and set_application_status both target a job, but their descriptions explicitly separate analysis fields from status timeline mechanics, so an agent should not confuse them.

Naming Consistency5/5

All tool names follow a uniform snake_case verb_noun style (analyze_job, list_jobs, save_resume_version, set_work_authorization). The verbs are specific and the objects are consistently named, with no camelCase or vague action words.

Tool Count5/5

Ten tools is right in the ideal range and every tool earns its place: job CRUD plus analysis, status tracking, resume versioning, and work authorization. There is no apparent redundancy or bloat.

Completeness4/5

The core job-application lifecycle is well covered: analyze, save, retrieve, list, update status, and delete jobs, plus resume version storage/retrieval and work authorization declaration. The only notable gap is the lack of a direct get_work_authorization tool, though analyze_job returns the live comparison during analysis.

Maintenance

ActivityMaintained
ResponsivenessNo issues

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables searching and analyzing H-1B visa sponsoring companies using U.S. Department of Labor data. Supports filtering by job role, location, and salary with natural language queries to find direct employers and export results.
    16
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    An MCP server that enables users to query H1B visa sponsorship data, approval rates, and top roles using public Department of Labor records. It provides tools for looking up company-specific stats and filtering sponsors by job title, city, or state.
    -
  • A
    license
    Not graded
    quality
    D
    maintenance
    Enables searching and analyzing real H-1B visa sponsorship data from the U.S. Department of Labor, including job titles, salaries, locations, and company statistics.
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    Enables searching and browsing hidden job market roles with H1B PERM sponsorship from major US companies, including tools for job search, details, categories, and company listings.
    -