Repository navigation
Conversation
shbernal
added a commit
to shbernal/linkedin-mcp-server
that referenced
this pull request
Nov 2, 2026
shbernal
marked this pull request as ready for review
November 2, 2026 15:49
Contributor
[High impact] The PR appears safe to merge; the blank-selection finding is fixed and no new actionable issues were found. SummaryAdds
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Read posting type] --> B{Easy Apply?}
B -->|No| C[Return posting type]
B -->|Yes| D[Open supported dialog]
D --> E[Resolve and fill step answers]
E --> F[Read fields back and check required answers]
F --> G{More steps?}
G -->|Yes| E
G -->|No| H{Explicit confirmation?}
H -->|No| I[Discard draft]
H -->|Yes| J[Click Submit and wait for form to close]
J --> K[Read optional confirmation]
Reviews (4) · Last reviewed commit: "fix(jobs): Read a select left on its pla..." · Reviewed by Greptile |
The step program threw its controls away once it had read them, so a form could be described and not answered. It now carries each field's control ids, which `_public_step` drops again, and an answer keyed by the label the read returned resolves into the edits that leave the field holding it. Every answer is resolved before the first one is carried out. LinkedIn saves Easy Apply answers to the account and prefills them into the next application, so a step half filled before a refusal is not undone by discarding the draft. Nothing is inferred. A label the form does not ask for, two fields wearing one label, an option that does not exist, an answer longer than the field holds and a missing resume each refuse, naming the field and what it takes. A typeahead refuses outright: its answer is a suggestion picked while typing, and a wrong one is what LinkedIn would save.
Opens the dialog, fills each step from the answers, clicks Next, and ends on the last step. Submit is clicked only when the caller asked for it and only once every step was reached holding an answer for each of its required fields. Without the flag the same walk fills the form, reads back what it would send and discards the draft, which is the rehearsal before a real application. Advancement is decided on the step's own heading and progress rather than on the signature the read walk compares. Answering a file field re-renders the step around the card for the uploaded file: same step, new controls. The read walk can compare signatures because it types nothing. The follow-the-company checkbox sits in the footer the step program skips, so it gets its own parameter and is left unchecked unless asked for; LinkedIn ships it checked. A dialog still holding the form after Submit is raised rather than reported, so nothing downstream records an application on the strength of a click.
Puts the answering walk on the MCP beside get_easy_apply_form, which is where its answers come from: they are keyed by the exact field labels that read returned. The result carries the same form, as the walk left it, beside what it did. Annotated destructive rather than idempotent. A submitted application cannot be taken back, and confirm_submit is the only thing that sends one, so the client prompts for it. The docstring is what an agent reads before calling, so it says the part that is not visible from the signature: every run writes to the account, submitted or not, because LinkedIn saves Easy Apply answers and prefills them into the next application.
The unit suite mocks page.evaluate, so nothing it covers has ever set a real control or run the programs the walk reads the dialog with. This runs the whole walk against a synthetic dialog in headless chromium: an open shadow root, LinkedIn's data attributes, required fields validated on Next, a file input behind an upload button, a resume list that grows a card for the uploaded file, and a footer holding the follow checkbox beside Submit. It covers the one shape the next live application has, a single step holding Submit in the footer slot from the start, and the claims worth having before one: every control type set from its label, the upload landing and its re-render not read as a refusal, Submit clicked once under the flag and never without it, and the follow checkbox unchecked from LinkedIn's checked default.
Two ways the first real application would have failed. A country list runs to 250 options and the read caps them at 50, so checking an answer against the options that survived refused every country past the cut. A list the read reports as cut is left for the browser to match, which is the only side that has the whole of it; a whole list is still checked here. A step is re-read after it is answered, and settling on two reads that agree happens before an upload comes back: LinkedIn only re-renders the step once it has the file. That left the walk deciding on the step as it stood before the resume was attached. After an upload it now waits for the step to change.
Measured on a live application: uploading a resume empties the form's resume list while the file is in flight, and the empty list holds still. Two reads agree on it, so the walk carried on with a form holding no resume at all, and under the submit flag that is an application sent with the resume the upload was meant to replace. Neither waiting for the step to change nor reading it once it stops changing sees this. The change fires when the list is torn down, and the torn-down list is what stops changing. So the file itself is the signal now: LinkedIn names an uploaded resume on the card it renders for it, and a file it never names back refuses rather than going out unattached. The follow checkbox came back checked on the same run, which the same rebuild explains, and it is now set again if it does not take the first time. Its real state was, and stays, what the result reports.
It came back checked on two live runs, for two reasons. The retry gave up without trying anything when LinkedIn left the input without an id, because the selector it had was keyed on one. And the input is styled invisible behind its label, which is a control a locator refuses to click rather than clicking something else on its behalf. So the box is tried from the most honest way down: check or uncheck on the input, found by its id or, failing that, as the footer's own checkbox; then the label, which is what a person clicks and what makes the browser toggle the box itself. A box that takes neither is still reported as it stands rather than claimed, because a company in a feed is not worth failing an application over.
The first real application came back with `confirmation: ""`. It had landed: the posting showed its applied line and `get_job_details` read it as applied. So the field said nothing on a submission that worked, and a caller with nothing else to go on cannot tell that case from a failure. The dialog closing is what proves the click landed, and that has not changed. What is read after it is the account of the send, and LinkedIn gives two. Its own modal is the one this looked for, and it does not always come. The posting's applied line is the other, and it is not a weaker stand-in: the modal is LinkedIn narrating the click, while the line is the posting's state having changed. A posting reading as applied never reaches the form, so the line cannot have been there before the click. Both are now polled for a few seconds, the modal first within a round because it renders immediately. An empty answer no longer reaches the caller at all: the key is dropped, because a key carrying "" reads as an account of something and `submitted` is what says the application was sent. The line is read above the description heading only, through the fragment `APPLY_SIGNALS_JS` already used for the applied state, so the two readers of the posting's state cannot come to disagree about where the boundary sits.
A typeahead is answered by naming it with the value to look up. The field is typed one character at a time, and what lands in it is the suggestion LinkedIn offered whose visible text is the answer, clicked. An answer no suggestion wears clears the field and refuses with the suggestions quoted, so the retry is one call away. An empty typeahead now refuses only when no answer names it, and a prefilled one can be replaced by naming it; both refused outright before, which is what made a wrong saved value unfixable. A prefill that already is the answer is left alone rather than retyped, so LinkedIn's binding of that text to an entity survives. Picks run before every other edit of the step, because a pick only learns whether it matches after its value has reached the page, and one that finds nothing has to leave the rest of the dialog as LinkedIn prefilled it.
The dialog renders inside the open shadow root of div#interop-outlet, so the document itself sees no dialog, no combobox and no suggestion list at all; only a locator that pierces the root reaches them. The suggestions render beside the input inside that root rather than portalled to the body, the field names its open list through both aria-owns and aria-controls, and no control anywhere carries the entity behind the text, which is why a pick can only be confirmed by the value read back and the list closing. The synthetic dialog now renders its list where LinkedIn renders it, so reading options off the document instead of through a locator fails five tests rather than passing them. Its hidden entity control stays, named for what it is: a stand-in for state LinkedIn keeps out of the DOM entirely, kept for the one behaviour both share, that nothing bound is what Next refuses on. A city query comes back ranked by the query alone and by nothing about the member, and its first suggestion is written the way LinkedIn writes a profile's location, so a value read off a profile names a real suggestion.
A posting whose Easy Apply is a button with no href, which get_job_apply_url reads as easy_apply since stickerdaniel#1250, opens a redesigned dialog: a native <dialog> in the document with no data attributes on Next, Submit or Discard. The form walk only knows the dialog the /apply/ anchor opens, so on a button-only posting it now refuses before opening anything rather than leave a draft it cannot find to discard.
submit_easy_apply had the read's gap: a cancelled walk raised past `except Exception` and left the dialog open on the shared page, holding the answers typed so far as a draft. It now closes the dialog the way the read does, shielded and bounded, on every exit.
submit_easy_apply raised the draft warning as a plain RuntimeError when the walk failed and the dialog stayed open, so the section error named RuntimeError instead of easy_apply_draft_left. It raises EasyApplyDraftLeftError, as the read does, and the workflow answers it with the draft section error and nothing sent.
The read now cuts a radio or checkbox group at 50 options, like a select. A select is matched by the browser, but a choice is clicked through the control its label stands for, and the labels past the cut were never read. Answering such a group refuses, naming the cut, instead of failing on labels and controls that no longer line up.
Since stickerdaniel#1376 the tools share one error envelope, and submit_easy_apply still spelled its own and imported the names that refactor removed. It now takes the decorator like the other tools, and the every-tool tests call it.
An answer that does not fit the field it names is shaped by the same branch as an invalid reference, so it names `invalid_argument` and the caller can correct its answers instead of retrying. `_BRANCHES` lists one failure per branch of `raise_tool_error`, and the refusal was missing from it.
The required-field check skipped every field an answer named, so an empty text or a lone checkbox answered no passed it: a rehearsal reported the form complete and a confirmed run clicked Next on a step LinkedIn would refuse. The step as read back decides now. Only a file answered on the step stays exempt, because its input never reports the file and the upload is waited for until the form names it instead.
The answering walk took any text a field described itself with as an error once Next was clicked. The step read carries hints there too, since LinkedIn names both through aria-describedby, so a step that moved onto a question with a hint ended the run as refused and the draft was discarded. Only a step Next did not move off is judged now, on the errors the click added, as the read walk does. The steps the walk returns drop hints the same way get_easy_apply_form's do.
Once Submit closed the dialog the application is sent, but a page read failing while the confirmation was polled raised out of the walk, and the tool answered submitted: false. A caller would be right to send it again. The failure is logged and the confirmation left out instead, which already says nothing about whether the application went out.
The upload wait took any label containing the file's name as the form naming it back, so a card for old-resume.pdf stood in for resume.pdf and the walk carried on before the upload landed. The name now has to stand on its own in the label. A card LinkedIn already held under the same name still cannot be told from the new one: nothing measured on the live dialog says which card is new or which is selected.
A select the read cut at 50 options was left for the browser to match, so a label missing from the whole list passed planning and select_option waited out its timeout, after the step's other answers had gone in. The caller got issue diagnostics instead of the field's refusal. The step read now keeps the whole list of a cut select beside the 50 it returns, and the planner checks the answer against it. The whole list stays out of the step the caller sees.
The typeahead branch returned its pick before the max_length check the other typed fields go through, so an answer longer than the field was typed in, cut by the browser, and refused for matching no suggestion. It now refuses with the length, while the step is still untouched.
submit_easy_apply asked for a ready extractor before it checked its job id, so a malformed one could start a browser, install Chromium or spend a login before the caller saw the refusal. It normalizes the id first, as get_easy_apply_form and dismiss_job do since stickerdaniel#1429, and joins their case in test_invalid_reference_is_rejected_before_extractor. The answers are checked against the form LinkedIn renders, so they stay with the extractor.
shbernal
force-pushed
the
feature/easy-apply-submit
branch
from
November 14, 2026 11:25
d04567d to
f816bf2
Compare
Contributor
Author
|
Rebased onto main after #1429 and the Easy Apply test fixes (#1441–#1445). |
The step read gives a select's selected option text as its value, so a required select still showing "Select an option" counted as answered: a rehearsal reported the form complete and a confirmed run clicked on. The read now flags a selected option that is a placeholder by its markup (an empty value, disabled or hidden), never by its translated words, and the required check treats a flagged select as empty. The flag stays private. The synthetic contact step's placeholder now has an empty value, as a browser-validated required select needs, and the country test answers the email it had been leaving on the placeholder.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1426
Summary
Adds
submit_easy_apply(job_id, answers, follow_company=false, confirm_submit=false), which answers a posting's Easy Apply form by the labelsget_easy_apply_formreturned and sends it only when asked to.{url, apply: {type}, submitted, form?: {steps, complete}, follow?, confirmation?}.formis shaped asget_easy_apply_form's, with the fields as the run left them. Any posting that is not Easy Apply answers with its type alone and is never opened.answersmaps a field's label to its answer: text for a typed field or a select, an option's label for a radio, a list of labels for a checkbox group, true or false for a lone checkbox, and a path on the server's machine for a resume upload. Nothing is inferred or defaulted. A field no answer names keeps LinkedIn's prefill._public_stepdrops them, soget_easy_apply_form's output is unchanged.max_length, a file that is not there, a radio or checkbox group the read cut at 50 options, and an empty typeahead nothing names. A required field still empty stops the run before Next, including one an answer emptied, such as""orfalse. An answer no step asked for refuses after the last step, before Submit. These raise the newInvalidAnswerError, which maps toinvalid_argumentwithout issue diagnostics, likeInvalidReferenceError.Enterwith no list open advances the step.complete: false, with only the errors the refused click added on its fields, and nothing sent. A step that moved is never judged on its text, so a hint on a newly reached step does not stop the run, and the returned steps drop hints asget_easy_apply_form's do. So does the eight-step cap.data-live-test-easy-apply-submit-button) is clicked only withconfirm_submit, and only after every step was reached with its required fields held. A dialog still open 15 seconds after the click raises; it is not reported as sent.confirmationis LinkedIn's modal text or, where no modal renders, the posting's applied line, polled for 5 seconds. It is absent when neither appears or the read fails after the dialog closed, and that says nothing about whether the application went out;submitteddoes. The applied line is read above the description heading through the same fragmentAPPLY_SIGNALS_JSuses, now shared.follow_companyis true. It is tried up to three times:check/uncheckon the input by id or as the footer's checkbox, then a click on its label when the input cannot be clicked.followreports the state it ended in. A box that will not change does not fail the run.confirm_submit, the form is filled, read back and the draft discarded. On every exit that did not submit, a cancelled call or a tool timeout included, the dialog is closed with layer 1's shielded, bounded discard before the error leaves. A draft that will not close issection_errors.formwitherror_type: easy_apply_draft_left, raised asEasyApplyDraftLeftErroras the read does. Any other walk failure (the dialog did not open, Submit left it open) issection_errors.formwith issue diagnostics andsubmitted: false.<button>with no href is refused before anything is clicked:section_errors.formwith the message "This posting's Easy Apply opens LinkedIn's redesigned dialog, which the form tools cannot read yet. Nothing was opened." The guard sits in the shared opener, soget_easy_apply_formnow answers the same immediately instead of after the five-second click timeout described in feat(jobs): Add get_easy_apply_form tool #1343.readOnlyHint: false,destructiveHint: true,idempotentHint: false,openWorldHint: true, and usestool_errorslike the other tools. The docstring says that every run writes to the account, submitted or not, because LinkedIn saves Easy Apply answers and prefills them into later applications. The plugin skill lists it as a write action that needs the user's authorization for the posting and the answers.AGENTS.mdgains a scraping rule: the Easy Apply dialog is not in the document.docs/easy-apply-markup.mdrecords the live typeahead markup.Documented in
AGENTS.md,CONTRIBUTING.md,README.md,docs/docker-hub.md,manifest.json, the plugin skill anddocs/easy-apply-markup.md.What this PR does not cover
This PR answers only the dialog that the posting's
/jobs/view/<id>/apply/anchor opens. Postings measured since 2026-09-25 render Easy Apply as a<button>with no href (#1248, #1250), which opens a redesigned dialog: a native<dialog>in the document whose buttons carry none of these data attributes. Both form tools refuse those postings before opening anything, as above. A follow-up PR will add reading and answering the redesigned dialog once this one merges.The walk clicks only Next (
data-easy-apply-next-button) and Submit. A form with a separate review page before Submit has not been seen live or tested.Live evidence
From the anchor's dialog, recorded in the commits and in
docs/easy-apply-markup.md. All of it is from 2026-09-23:aria-ownsandaria-controlsnaming it,role="option"suggestions, an emptyaria-activedescendant, and no control carrying the entity. Suggestions came within 300 ms. Clicking a suggestion was not tried live; the pick is tested only against the synthetic dialog.confirmation: "": no modal rendered, while the posting showed its applied line andget_job_detailsread it as applied. The applied-line fallback came from that run. It has not been observed live since it was added.The live applications were single-step forms holding Submit from the start. A multi-step submission has not been run live. Whether a run that does not submit saves its answers to the account is listed as not measured in
docs/easy-apply-markup.md; the docstring takes the cautious reading. The button-shaped refusal follows from the code and was not observed live. Nothing was rerun on this exact commit.Verification
tests/test_easy_apply_submit_dom.py(browser_dom) runs the whole walk in headless chromium against a synthetic dialog: an open shadow root, LinkedIn's data attributes, required fields validated on Next, a hidden file input behind an upload button, a resume list that grows a card for the file, a typeahead whose suggestions render in the same root and bind an entity only when clicked, and Submit beside the follow checkbox in the footer. It logs every click. The tests cover every control type set from its label; an uploaded file landing, its re-rendered step not read as a refusal, a list torn down by the upload waited out, and a file never shown back not submitted; the draft discarded without the flag and after an AnyIO deadline mid-walk; a required field nothing answers refusing before any click; Submit clicked once with every answer; the follow checkbox unchecked by default, left checked when asked, and unchecked through its label when the input is hidden; a one-step form submitting from its footer; a send with no modal confirmed by the posting's line, and a matching line in the description not taken for it; and the typeahead picked by its text, refused with the suggestions quoted, a refused pick leaving the step as prefilled, a prefill replaced or kept, an empty unnamed typeahead refused, typed text without a pick not answering the step, and a suggestion LinkedIn does not take refused.tests/test_easy_apply_form_dom.pyadds a button-shaped posting: the read refuses with nothing clicked.tests/linkedin/test_job_pages.py:TestAnswerEasyApplyStepcovers how each answer resolves into edits and every refusal above.TestSubmitEasyApplycovers the walk on a mocked page: only the flag clicks Submit, the confirmation sources and their order, a failed confirmation read that keeps the send, a hint on a newly reached step that does not stop the run, a cut select checked against its whole list, a refused or stalled step, the upload waits, the follow checkbox fallbacks, unused answers, the step cap, a dialog still open after Submit, and a non-Easy Apply posting. A failed walk that leaves a draft raisesEasyApplyDraftLeftError.tests/linkedin/test_jobs.pycovers the workflow: a sent application with and without a confirmation, a rehearsal, a refused answer reaching the caller, a failed walk as a section error with nothing sent, a draft left beside the form or in place of it, and any other posting type.tests/test_error_handler.pyandtests/test_error_codes.pycoverInvalidAnswerErrorasinvalid_argumentwithout diagnostics, andtests/test_tools.pyadds the tool to the every-tool tests.submit-easy-applyscenario) and the architecture reference were regenerated.ruff,ty, the architecture check and the policy-trace check pass.a25dbb61), run from the branch stacked on it: every job passed, thetestjob included.Synthetic prompt
Screenshots
README.md, tool table:docs/docker-hub.md, features:Implemented with Claude Opus 5.5 (high) in Claude Code via tmux.
Note
Add
submit_easy_applytool for answering and sending Easy Apply formssubmit_easy_applyMCP tool registered in job.py, exposed throughLinkedInExtractorandJobReader. It takes a job ID, a label-to-answer mapping, afollow_companyflag, and aconfirm_submitflag; without confirmation it fills the form, discards the draft, and does not send.div#interop-outlet, plans answers per control type (text, select, radio, checkbox, textarea, file upload, typeahead suggestions), and clicks Next/Submit. Refusal messages live in job_policy.py.InvalidAnswerErrorin exceptions.py and classifies it asINVALID_ARGUMENTin error_handler.py, passing refusal messages to the caller without diagnostics.submit_easy_applyis annotated destructive and non-idempotent —confirm_submitsends an application that cannot be withdrawn, and answers are saved to the account. The redesigned Easy Apply button is refused before opening (EASY_APPLY_BUTTON_UNREAD), and a failed walk that cannot close the draft raisesEasyApplyDraftLeftError.Macroscope summarized d04567d.