Skip to content

feat(profile-edit): add 13 tools to edit every profile section#314

Closed
Gabrcodes wants to merge 774 commits into
stickerdaniel:mainfrom
Gabrcodes:feat/profile-editing
Closed

feat(profile-edit): add 13 tools to edit every profile section#314
Gabrcodes wants to merge 774 commits into
stickerdaniel:mainfrom
Gabrcodes:feat/profile-editing

Conversation

@Gabrcodes
Copy link
Copy Markdown

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.

Tool What it edits
edit_profile_intro First name, last name, headline, location, industry
edit_profile_about About/Summary section text
add_experience Work experience entries
add_education Education entries
add_skill Skills (with typeahead suggestion support)
add_certification Certifications with credential ID/URL
add_volunteer_experience Volunteer work
add_project Projects with URL
add_publication Publications
add_course Courses
add_language Languages with proficiency level
add_honor Honors and awards

Implementation notes

  • _fill_field_by_label uses Object.getOwnPropertyDescriptor with the correct prototype per element type (HTMLTextAreaElement, HTMLSelectElement, or HTMLInputElement) so React observes changes
  • _select_dropdown_by_label uses 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 call

Changes

  • scraping/extractor.py — all edit helper methods and _resolve_my_username
  • tools/profile_edit.py — new file with 13 tool registrations
  • server.py — register profile edit tools

Test plan

  • 357 passed, 5 skipped, 0 failures
  • ruff check and ruff format pass
  • Live test: edit headline via edit_profile_intro
  • Live test: add a skill via add_skill

stickerdaniel and others added 30 commits March 4, 2026 20:38
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.
stickerdaniel and others added 14 commits March 30, 2026 18:01
- 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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 2, 2026

Greptile Summary

This PR adds 12 new MCP tools for editing the authenticated user's LinkedIn profile. All prior review concerns have been resolved: edit_profile_about now targets contenteditable elements, industry correctly waits for and clicks a typeahead suggestion, _resolve_my_username is instance-cached, the required-field guard in _edit_profile_section_entry now aborts on any missing required field, and the get_my_profile dead code has been removed. The implementation consistently avoids LinkedIn-specific CSS class names and relies on label text and standard HTML attributes throughout.

Confidence Score: 5/5

Safe 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

Filename Overview
linkedin_mcp_server/scraping/extractor.py Adds 12 edit helper methods and 4 private helpers (_fill_field_by_label, _select_dropdown_by_label, _click_save_in_dialog, _resolve_my_username with caching); all previous review concerns (contenteditable about, industry typeahead, username caching, required-field guard, dead code) are addressed
linkedin_mcp_server/tools/profile_edit.py New file registering 12 MCP tools for profile editing; follows existing patterns for auth handling, progress reporting, and destructiveHint annotation
linkedin_mcp_server/server.py Adds import and registration call for profile_edit tools; minimal, correct change
tests/test_tools.py Adds TestProfileEditTool class with 12 happy-path tests and a shared helper; docstring incorrectly says '13 profile editing tools' when 12 are implemented
linkedin_mcp_server/tools/init.py Updates module docstring to list the new profile-edit and connections tool categories

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"]
Loading
Prompt To Fix All With AI
This 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

Comment thread linkedin_mcp_server/scraping/extractor.py
Comment thread linkedin_mcp_server/scraping/extractor.py Outdated
Comment thread linkedin_mcp_server/scraping/extractor.py Outdated
Comment thread linkedin_mcp_server/scraping/extractor.py Outdated
Gabrcodes and others added 2 commits April 3, 2026 01:28
…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>
Comment thread linkedin_mcp_server/scraping/extractor.py Outdated
Comment thread linkedin_mcp_server/scraping/extractor.py Outdated
Gabrcodes and others added 7 commits April 3, 2026 02:30
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants