feat(profile-edit): add 13 tools to edit every profile section#314
feat(profile-edit): add 13 tools to edit every profile section#314Gabrcodes wants to merge 774 commits into
Conversation
Lock file already has 3.1.0 since #166; align pyproject.toml
floor to prevent accidental downgrades to v2.
Resolves: #190
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR tightens the `fastmcp` minimum version constraint from `>=2.14.0` to `>=3.0.0` in `pyproject.toml` (and the corresponding `uv.lock` metadata), preventing any future resolver from backtracking to the incompatible v2 series. The lock file has already been pinning `fastmcp==3.1.0` since PR #166, so there is no runtime impact — this is purely a spec/metadata alignment.
- `pyproject.toml`: `fastmcp` floor raised to `>=3.0.0`
- `uv.lock`: `package.metadata.requires-dist` updated to match; the resolved package entry (`3.1.0`) is unchanged
- No upper-bound cap (`<4.0.0`) is set, which is consistent with the project's existing open-ended constraints for all other dependencies
<h3>Confidence Score: 5/5</h3>
- This PR is safe to merge — it is a pure metadata alignment with no functional or runtime impact.
- The locked version was already `3.1.0` before this PR; the only change is raising the declared floor to match. Both modified lines are trivially correct, consistent with each other, and have no side-effects on the installed environment.
- No files require special attention.
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| pyproject.toml | Single-line change updating the `fastmcp` floor constraint from `>=2.14.0` to `>=3.0.0`, aligning with the already-resolved version in the lock file. |
| uv.lock | Auto-generated lock file metadata updated to reflect the new `>=3.0.0` specifier; the resolved `fastmcp` version (3.1.0) was already correct and unchanged. |
</details>
<h3>Flowchart</h3>
```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["pyproject.toml\nfastmcp >=3.0.0"] -->|uv resolves| B["uv.lock\nfastmcp 3.1.0 (pinned)"]
B --> C["Installed environment\nfastmcp 3.1.0"]
D["Old constraint\nfastmcp >=2.14.0"] -. "could resolve to" .-> E["fastmcp 2.x\n(incompatible)"]
style D fill:#f9d0d0,stroke:#c00
style E fill:#f9d0d0,stroke:#c00
style A fill:#d0f0d0,stroke:#060
style B fill:#d0f0d0,stroke:#060
style C fill:#d0f0d0,stroke:#060
```
<sub>Last reviewed commit: 7d2363e</sub>
<!-- greptile_other_comments_section -->
<!-- /greptile_comment -->
Replace dict-returning handle_tool_error() with raise_tool_error() that raises FastMCP ToolError for known exceptions. Unknown exceptions re-raise as-is for mask_error_details=True to handle. Resolves: #185
Add logger.error with exc_info for unknown exceptions before re-raising, and add test coverage for AuthenticationError and ElementNotFoundError.
Re-add optional context parameter to raise_tool_error() for log correlation, and add test for base LinkedInScraperException branch.
Add catch-all comment on base exception branch and NoReturn inline comments on all raise_tool_error() call sites.
…mcp_constraint_to_3.0.0 refactor(error-handler): replace handle_tool_error with ToolError
Replace repeated ensure_authenticated/get_or_create_browser/ LinkedInExtractor boilerplate in all 6 tool functions with FastMCP Depends()-based dependency injection via a single get_extractor() factory in dependencies.py. Resolves: #186
Updated the get_extractor function to route errors through raise_tool_error, ensuring that MCP clients receive structured ToolError responses for authentication failures. Added a test to verify that authentication errors are correctly handled and produce the expected ToolError response.
…epends_to_inject_extractor refactor(tools): Use Depends() to inject extractor
Replace ToolAnnotations(...) with plain dicts, move title to top-level @mcp.tool() param, and add category tags to all tools. Resolves: #189
Replace ToolAnnotations(...) with plain dicts, move title to
top-level @mcp.tool() param, and add category tags to all tools.
Resolves: #189
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR is a clean, well-scoped refactoring that modernises tool metadata across all four changed files to align with the FastMCP 3.x API. It introduces no functional or behavioural changes.
Key changes:
- Removes the `ToolAnnotations(...)` Pydantic wrapper in `company.py`, `job.py`, and `person.py`, replacing it with plain `dict` syntax for the `annotations` parameter — the simpler form supported by FastMCP 3.x.
- Moves `title` from inside `ToolAnnotations` to a top-level keyword argument on `@mcp.tool()`, matching the updated FastMCP 3.x decorator signature.
- Drops the now-redundant `destructiveHint=False` from all read-only tools. Per the MCP spec, `destructiveHint` is only meaningful when `readOnlyHint` is `false`, so omitting it from tools that already declare `readOnlyHint=True` is semantically equivalent.
- Adds `tags` (as Python `set` literals) to every tool for categorisation (`"company"`, `"job"`, `"person"`, `"scraping"`, `"search"`, `"session"`).
- Enriches the previously unannotated `close_session` tool in `server.py` with a title, `destructiveHint=True`, and the `"session"` tag — accurately describing its destructive nature.
The existing test suite in `tests/test_tools.py` covers all tool functions but does not assert on annotation metadata, so no test changes are required. The refactoring is consistent across all tool files and fits naturally within the project's layered registration pattern.
<h3>Confidence Score: 5/5</h3>
- This PR is safe to merge — it is a pure metadata/annotation refactoring with no changes to tool logic, inputs, outputs, or error handling.
- All changes are limited to decorator parameters (`title`, `annotations`, `tags`). The `annotations` dict values are semantically equivalent to the removed `ToolAnnotations` objects, `destructiveHint=False` is correctly dropped only for `readOnlyHint=True` tools, and the new `close_session` annotations accurately reflect its destructive nature. No business logic, scraping behaviour, or error paths were altered.
- No files require special attention.
<h3>Flowchart</h3>
```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["@mcp.tool() decorator"] --> B{Annotation style}
B -->|Before| C["ToolAnnotations(title=..., readOnlyHint=..., destructiveHint=False, openWorldHint=...)"]
B -->|After| D["title='...' (top-level param)\nannotations={'readOnlyHint': True, 'openWorldHint': True}\ntags={'category', 'type'}"]
D --> E["person tools\n(get_person_profile, search_people)"]
D --> F["company tools\n(get_company_profile, get_company_posts)"]
D --> G["job tools\n(get_job_details, search_jobs)"]
D --> H["session tool\n(close_session)\nannotations={'destructiveHint': True}"]
```
<sub>Last reviewed commit: c5bf554</sub>
<!-- greptile_other_comments_section -->
<!-- /greptile_comment -->
Use lowercase dict instead of Dict, add auth validation log line
…t_lifespan_into_composable_browser_auth_lifespans refactor(server): Split lifespan into composable browser + auth lifespans
# Conflicts: # linkedin_mcp_server/server.py # linkedin_mcp_server/tools/company.py # linkedin_mcp_server/tools/job.py # linkedin_mcp_server/tools/person.py
# Conflicts: # linkedin_mcp_server/server.py # linkedin_mcp_server/tools/company.py # linkedin_mcp_server/tools/job.py # linkedin_mcp_server/tools/person.py
# Conflicts: # linkedin_mcp_server/server.py
…_timeouts feat(tools): add global 90s tool timeouts
…_jobs Extract job IDs from href attributes (the one thing innerText can't capture), scroll the job sidebar instead of the main page, and paginate through multiple result pages with dynamic offsets. Resolves: #195
- Use fixed 25-per-page offset instead of dynamic ID count - Read "Page X of Y" from pagination state to cap pagination - Add soft rate-limit retry via _extract_search_page helper - Use keyword arguments in tool wrapper for clarity
- Stop on page 0 when no job IDs found (avoid useless page 1) - Fix test_stops_at_total_pages to use distinct IDs per page so only the total_pages guard stops pagination
Add date_posted, job_type, experience_level, work_type, easy_apply, and sort_by filters to search_jobs with human-readable normalization. Fix Greptile review: always log no-results break, move _PAGE_SIZE to module level, add Field(ge=1, le=10) on max_pages, skip ID extraction on empty text. Resolves: #174
Use _normalize_csv for job_type to preserve raw commas in multi-value filters and add human-readable names (full_time, contract, etc.).
Break early when _extract_search_page returns _RATE_LIMITED_MSG to avoid extracting IDs from unreliable DOM state. Remove redundant truthiness check now guarded by the early break.
Move _normalize_csv out of _build_job_search_url to module level for reusability. Wait for job card links before sidebar scrolling to handle async rendering. Document DOM-independence principle in CONTRIBUTING.md and AGENTS.md.
- Remove unused selector constants (_MESSAGING_THREAD_LINK_SELECTOR, _MESSAGING_RESULT_ITEM_SELECTOR, _MESSAGING_SEND_SELECTOR) - Remove dead _conversation_thread_cache (new extractor per tool call) - Add AuthenticationError handling to get_sidebar_profiles and all messaging tools - Pass CSS selector as evaluate() arg instead of f-string interpolation - Replace deprecated execCommand with press_sequentially - Guard sidebar container walk against depth-limit exhaustion - Update scrape_person docstring to document profile_urn return key - Add messaging tools to README tool-status table
LinkedIn redirects /messaging/ to the most recent thread; capture baseline_thread_id after the SPA settles so search-selected threads can be distinguished from the auto-opened one.
feat: linkedin messaging, get sidebar profiles
…IDs (#300) * fix(scraping): Respect --timeout for messaging, recognize thread URLs Remove all hardcoded timeout=5000 from the send_message flow and messaging helpers so they fall through to the page-level default set from BrowserConfig.default_timeout (configurable via --timeout). Also add /messaging/thread/ URL recognition to classify_link so conversation thread references are captured when they appear in search results or conversation detail views. Raise inbox reference cap to 30 and add proper section context labels. Resolves: #296 See also: #297 * fix(scraping): Extract conversation thread IDs from inbox via click-and-capture LinkedIn's conversation sidebar uses JS click handlers instead of <a> tags, so anchor extraction cannot capture thread IDs. Click each conversation item and read the resulting SPA URL change to build conversation references with thread_id and participant name. Before: get_inbox returned 2 references (active conversation only) After: get_inbox returns all conversation thread IDs (10+ refs) Resolves: #297 * fix(scraping): Respect --timeout across all remaining scraping methods Remove the remaining 10 hardcoded timeout=5000 from profile scraping, connection flow, modal detection, sidebar profiles, conversation resolution, and job search. All Playwright calls now use the page-level default from BrowserConfig.default_timeout. Resolves: #299 * fix: Address PR review feedback - Use saved inbox URL instead of self._page.url (P1: wrong URL after clicks) - Fix docstring to clarify 2s recipient-picker probe is intentional - Replace class-name selectors with aria-label discovery + minimal class fallback - Dedupe references after merging conversation and anchor refs
First-time uvx runs download ~77 Python packages including the 39MB patchright wheel. On slow connections, uv's default 30s HTTP timeout can cause silent failures before the server process starts. Co-authored-by: Daniel Sticker <sticker@ngenn.net>
Move UV_HTTP_TIMEOUT=300 into the main uvx config example so it's the default, not an optional troubleshooting step. Fix grammar in the troubleshooting note. Co-authored-by: Daniel Sticker <sticker@ngenn.net>
* docs: use @latest tag in uvx config for auto-updates Without @latest, uvx caches the first downloaded version forever. Adding @latest ensures uvx checks PyPI on each client launch and pulls new versions automatically. * docs: apply @latest consistently to all uvx invocations Update --login examples in README.md and docs/docker-hub.md to use linkedin-scraper-mcp@latest for consistency with the MCP config. --------- Co-authored-by: Daniel Sticker <sticker@ngenn.net>
New destructive tools for managing the authenticated user's LinkedIn profile. All use LinkedIn's overlay edit forms and fill fields by label text. | Tool | Edits | |---|---| | edit_profile_intro | First name, last name, headline, location, industry | | edit_profile_about | About/Summary text | | add_experience | Work experience (title, company, dates, location, description) | | add_education | Education (school, degree, field, dates, grade) | | add_skill | Skills with typeahead suggestion support | | add_certification | Certifications (name, org, dates, credential ID/URL) | | add_volunteer_experience | Volunteer work (org, role, cause, dates) | | add_project | Projects (name, description, dates, URL) | | add_publication | Publications (title, publisher, date, URL) | | add_course | Courses (name, number, association) | | add_language | Languages (name, proficiency level) | | add_honor | Honors and awards (title, issuer, date) | Uses correct HTMLSelectElement.prototype native setter for <select> elements so React observes the change. All tools are annotated destructiveHint. Uses _resolve_my_username() to navigate to the correct edit overlay. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds 12 new MCP tools for editing the authenticated user's LinkedIn profile. All prior review concerns have been resolved: Confidence Score: 5/5Safe to merge; all prior P1 concerns are addressed and the only remaining finding is a minor docstring count mismatch. All previously flagged P1 issues (contenteditable about editor, industry typeahead, username caching, partial-save guard, dead code) are resolved in this revision. The sole new finding is a P2 documentation discrepancy (12 tools labelled as 13) that has no runtime impact. No files require special attention beyond the minor docstring fix in tests/test_tools.py. Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
T["MCP Tool call\n(profile_edit.py)"] --> GE["get_ready_extractor()"]
GE --> RU["_resolve_my_username()\n(cached in _my_username_cache)"]
RU --> NAV["_navigate_to_page(URL)\nLinkedIn overlay form"]
NAV --> WAIT["wait_for_selector\n(dialog / main form)"]
WAIT -->|timeout| FAIL["return edit_failed"]
WAIT --> FILL["_fill_field_by_label()\nJS: label → input/textarea/select\nnative React setter + events"]
FILL --> DROP["_select_dropdown_by_label()\nJS: label → select\noption text search"]
DROP --> REQ{required_fields\nall filled?}
REQ -->|no| ABORT["return edit_failed\nmissing fields listed"]
REQ -->|yes| SAVE["_click_save_in_dialog()\nSave / Apply / Done button"]
SAVE -->|found| OK["return saved"]
SAVE -->|not found| SF["return save_failed"]
Prompt To Fix All With AIThis is a comment left during a code review.
Path: tests/test_tools.py
Line: 749
Comment:
**Tool count mismatch: 12 tools, not 13**
The PR title, PR description, and this docstring all say "13" tools, but exactly 12 are implemented and registered (confirmed by the 12 mock methods in `_make_mock_extractor` and the 12 tool functions in `profile_edit.py`).
```suggestion
"""Tests for the 12 profile editing tools."""
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (11): Last reviewed commit: "fix: remove redundant case-insensitive s..." | Re-trigger Greptile |
…dead code
- fix(extractor): prevent partial saves in _edit_profile_section_entry —
add required_fields param; if a required label can't be filled the
method returns edit_failed instead of saving incomplete data.
add_experience requires Title+Company, add_education requires School.
- fix(extractor): remove artdeco/fb CSS class selectors from
_fill_field_by_label fallback — violates CLAUDE.md; replaced with
generic label.closest('div')
- refactor(extractor): cache _resolve_my_username result per instance
to avoid redundant /in/me/ navigations across multiple edit calls;
also add 'me' guard against redirect not completing
- refactor(extractor): remove dead get_my_profile method — not registered
as a tool in this PR (that belongs in the account-tools PR)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…12 tests
- fix(extractor): _fill_field_by_label and _select_dropdown_by_label
used document.querySelector(scope) which returns the first DOM match
(always <main> on LinkedIn since dialog portals are appended after it).
Fixed to use document.querySelectorAll(scope) + flatMap so all
matching roots are searched — dialogs are now found correctly.
- fix(extractor): add required_fields={Name, Issuing organization} to
add_certification to prevent partial saves
- test: add TestProfileEditTool with 12 tests covering all edit tools
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…pdown - fix: edit_profile_about targets contenteditable div (LinkedIn's About editor) with textarea fallback — previous textarea locator never matched, making the tool always return edit_failed - fix: edit_profile_intro industry now clicks typeahead suggestion after filling — LinkedIn ignores DOM value changes on custom autocomplete fields without a selection from the suggestions list - fix: add_course routes associated_with through _select_dropdown_by_label since it is a <select> of education entry IDs, not a text input Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…fields
- fix: only append 'industry' to fields_updated when a typeahead
suggestion was actually clicked — previously reported success even
when the listbox was empty and LinkedIn would ignore the value on save
- fix: add required_fields={'Organization', 'Role'} to
add_volunteer_experience to prevent partial saves when label
matching fails (same guard already used by experience/certification)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
City is a plain text input that saves directly. Country/Region is a typeahead that silently fails without a suggestion click. The previous or-chain tried Country/Region first, short-circuiting City and leaving the location unchanged while still reporting fields_updated=[location]. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…che in __init__ - fix: add required_fields guard to add_project, add_publication, add_course, add_language, add_honor — same partial-save protection already present on experience/education/certification/volunteer. Primary field (Name/Title/Course name) must fill or save is aborted. - refactor: declare _my_username_cache: str | None = None in __init__ so the attribute is discoverable; replace getattr check with direct attribute access. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… matching - fix(P1): rewrite add_language with custom implementation (like add_skill) — LinkedIn's language name is an autocomplete that ignores raw DOM value changes; fills the field, waits for typeahead suggestions, clicks first match, then selects proficiency via dropdown - fix(P2): route all year fields (Start/End/Issue/Expiration/Publication date year) through _select_dropdown_by_label instead of _fill_field_by_label — searches by visible option text rather than raw value, making it resilient to option value/display mismatches; also fixes add_education missing dropdowns declaration Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
_fill_field_by_label already performs case-insensitive comparison via .toLowerCase() in JS, so 'skill' and 'Skill' produce identical results — the second call is unreachable dead code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
13 new tools for editing the authenticated user's LinkedIn profile. All tools use LinkedIn's overlay edit forms, fill fields by their label text (no fragile CSS class selectors), and are annotated
destructiveHint.edit_profile_introedit_profile_aboutadd_experienceadd_educationadd_skilladd_certificationadd_volunteer_experienceadd_projectadd_publicationadd_courseadd_languageadd_honorImplementation notes
_fill_field_by_labelusesObject.getOwnPropertyDescriptorwith the correct prototype per element type (HTMLTextAreaElement,HTMLSelectElement, orHTMLInputElement) so React observes changes_select_dropdown_by_labeluses the same native setter pattern for<select>elements_resolve_my_username()navigates to/in/me/once and caches the result for all edit methods in the same callChanges
scraping/extractor.py— all edit helper methods and_resolve_my_usernametools/profile_edit.py— new file with 13 tool registrationsserver.py— register profile edit toolsTest plan
ruff checkandruff formatpassedit_profile_introadd_skill