Skip to content

feat: linkedin messaging, get sidebar profiles#291

Merged
stickerdaniel merged 7 commits intostickerdaniel:mainfrom
aspectrr:aspectrr/linkedin-connect
Mar 30, 2026
Merged

feat: linkedin messaging, get sidebar profiles#291
stickerdaniel merged 7 commits intostickerdaniel:mainfrom
aspectrr:aspectrr/linkedin-connect

Conversation

@aspectrr
Copy link
Copy Markdown
Contributor

No description provided.

aspectrr and others added 3 commits March 29, 2026 18:05
…file

- Add get_sidebar_profiles() extractor method that scrapes sidebar sections
  (More profiles for you, Explore premium profiles, People you may know),
  follows Show all links, and skips any /premium redirects
- Add _extract_profile_urn() helper that reads the recipient URN from the
  Message button compose href on the current profile page
- Expose profile_urn in scrape_person results when available
- Register get_sidebar_profiles MCP tool in person.py
- Add to manifest.json and README tool table
- Tests: TestGetSidebarProfiles, TestExtractProfileUrn, TestScrapePersonProfileUrn,
  TestGetSidebarProfilesTool
Adds four messaging tools: get_inbox, get_conversation, search_conversations,
and send_message (with profile_urn bypass for reliable compose URL routing).
Includes all browser helper methods and full test coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat: add get_sidebar_profiles tool and profile_urn in get_person_profile
Copilot AI review requested due to automatic review settings March 29, 2026 22:26
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 29, 2026

Greptile Summary

This PR adds five new tools to the LinkedIn MCP server: get_sidebar_profiles (extracts profile recommendations from a person's page sidebar), get_inbox (lists recent messaging conversations), get_conversation (reads a specific thread by username or thread ID), search_conversations (keyword search across messages), and send_message (sends a message with an explicit confirm_send gate and optional profile URN shortcut). It also extracts and returns the profile_urn from a person's profile page, which can be fed back to send_message for a more reliable compose-URL construction path.

Key observations:

  • The messaging infrastructure is well-structured, with fallback selectors, graceful error returns for each failure mode, and a dry-run mode via confirm_send=False.
  • send_message contains a P1 logic issue: the post-send verification (_message_text_visible) searches document.body.innerText for the message text, but that text is already present in the compose box immediately after typing — the check passes regardless of whether the send actually succeeded.
  • get_sidebar_profiles returns {url, sidebar_profiles: {...}} instead of the project-standard {url, sections: {name: text}} documented in CLAUDE.md.
  • Test coverage is solid across both the extractor layer and the tool layer, including dry-run, profile URN, premium-redirect, and error-path cases.

Confidence Score: 4/5

Mostly safe to merge; one P1 issue means send_message can return sent: True even on a failed send, which should be fixed before production use.

The P1 issue in _message_text_visible means the sent field in the send_message response is not a reliable confirmation — callers cannot trust sent: True as proof of delivery. All other changes are well-implemented and tested.

linkedin_mcp_server/scraping/extractor.py — specifically the _message_text_visible method and its caller in send_message.

Important Files Changed

Filename Overview
linkedin_mcp_server/scraping/extractor.py Adds ~560 lines of new scraping logic for messaging inbox, conversation reading, search, sending, and sidebar profiles. Contains a P1 issue where _message_text_visible will always return True after typing, making the sent-confirmation unreliable.
linkedin_mcp_server/tools/messaging.py New file registering four MCP tools (get_inbox, get_conversation, search_conversations, send_message). Error handling and progress reporting patterns are consistent with existing tools.
linkedin_mcp_server/tools/person.py Adds get_sidebar_profiles tool; clean and consistent with existing patterns, though the return format deviates from the project-standard {url, sections: {}} envelope.
tests/test_scraping.py Good test coverage for all new extractor methods including dry-run, unavailable, and profile URN paths.
tests/test_tools.py Tool-layer tests for all five new tools including error paths; timeout registration checks updated correctly.

Sequence Diagram

sequenceDiagram
    participant C as MCP Client
    participant T as send_message tool
    participant E as LinkedInExtractor
    participant LI as LinkedIn (browser)

    C->>T: send_message(username, message, confirm_send, profile_urn?)
    T->>E: send_message(username, message, confirm_send, profile_urn)
    E->>LI: navigate /in/{username}/
    E->>LI: read profile display name
    alt profile_urn provided
        E-->>E: compose_url = /messaging/compose/?recipient={urn}
    else
        E->>LI: find messaging compose link href
    end
    E->>LI: navigate compose_url
    alt recipient_picker surface
        E->>LI: select recipient by display name
        alt selection fails
            E-->>T: recipient_resolution_failed
        end
    end
    E->>LI: verify compose page matches recipient
    alt confirm_send == False
        E-->>T: confirmation_required, sent: false
    end
    E->>LI: type message in compose box
    E->>LI: wait for send button enabled
    E->>LI: click send button
    E->>LI: _message_text_visible checks body.innerText
    Note over E,LI: Text already in compose box after typing - check may pass prematurely
    alt text found in body
        E-->>T: sent: true
    else
        E-->>T: send_unavailable, sent: false
    end
    T-->>C: result dict
Loading

Comments Outside Diff (1)

  1. linkedin_mcp_server/scraping/extractor.py, line 1064-1071 (link)

    _message_text_visible always returns True after typing — unreliable send confirmation

    _message_text_visible checks document.body.innerText for the message text. However, at the point this check is called, the message was already typed into the compose box (lines 1022–1023) and is therefore already present in document.body.innerText. The wait_for_function will satisfy on the very first poll because the text is still in the compose box, regardless of whether the send button click actually caused a successful delivery.

    If the send fails (network error, LinkedIn validation error, etc.), LinkedIn typically retains the message text in the compose box so the user can retry. In that case _message_text_visible returns True, sent=True is emitted, and callers have no reliable signal that the message was not delivered.

    A more reliable approach is to verify the compose box is cleared after the click rather than checking that the text exists anywhere on the page:

    async def _compose_box_cleared(self, *, timeout: int = 5000) -> bool:
        try:
            await self._page.wait_for_function(
                """(selector) => {
                    const el = document.querySelector(selector);
                    return el ? (el.innerText || el.textContent || '').trim() === '' : true;
                }""",
                arg=_MESSAGING_COMPOSE_SELECTOR,
                timeout=timeout,
            )
            return True
        except PlaywrightTimeoutError:
            return False
Prompt To Fix All With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 1064-1071

Comment:
**`_message_text_visible` always returns `True` after typing — unreliable send confirmation**

`_message_text_visible` checks `document.body.innerText` for the message text. However, at the point this check is called, the message was already typed into the compose box (lines 1022–1023) and is therefore *already present* in `document.body.innerText`. The `wait_for_function` will satisfy on the very first poll because the text is still in the compose box, regardless of whether the send button click actually caused a successful delivery.

If the send fails (network error, LinkedIn validation error, etc.), LinkedIn typically retains the message text in the compose box so the user can retry. In that case `_message_text_visible` returns `True`, `sent=True` is emitted, and callers have no reliable signal that the message was not delivered.

A more reliable approach is to verify the compose box is **cleared** after the click rather than checking that the text exists anywhere on the page:

```python
async def _compose_box_cleared(self, *, timeout: int = 5000) -> bool:
    try:
        await self._page.wait_for_function(
            """(selector) => {
                const el = document.querySelector(selector);
                return el ? (el.innerText || el.textContent || '').trim() === '' : true;
            }""",
            arg=_MESSAGING_COMPOSE_SELECTOR,
            timeout=timeout,
        )
        return True
    except PlaywrightTimeoutError:
        return False
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 256-268

Comment:
**`get_sidebar_profiles` return format deviates from project standard**

Per the project's style guide, all scraping tools must return `{url, sections: {name: raw_text}}`. `get_sidebar_profiles` returns `{url, sidebar_profiles: {...}}` instead, bypassing the `sections` envelope.

Consider wrapping the result for consistency:

```python
return {
    "url": url,
    "sections": {"sidebar_profiles": sidebar_profiles},
}
```

Or explicitly document the intentional deviation in the docstring and CLAUDE.md.

**Context Used:** CLAUDE.md ([source](https://app.greptile.com/review/custom-context?memory=e3726abd-137d-439d-b03c-d01e1ba139d4))

How can I resolve this? If you propose a fix, please make it concise.

Reviews (4): Last reviewed commit: "fix: Add baseline comment for messaging ..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds new LinkedIn capabilities to the MCP server: scraping sidebar “recommended profiles” from a person page, and a new messaging tool suite (inbox/conversations/search/send), backed by new extractor functionality and tests.

Changes:

  • Add get_sidebar_profiles tool and extractor support for sidebar recommendation sections.
  • Introduce messaging tools (get_inbox, get_conversation, search_conversations, send_message) and register them on the server.
  • Extend scraping/extractor with profile URN extraction and messaging page automation; update manifest/tests/docs accordingly.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
tests/test_tools.py Adds tool-level tests for sidebar + messaging tools and updates timeout coverage list.
tests/test_scraping.py Adds extractor-level tests for sidebar profiles, profile URN extraction, and messaging flows.
manifest.json Publishes new tool names/descriptions in the MCP bundle manifest.
linkedin_mcp_server/tools/person.py Registers the new get_sidebar_profiles MCP tool.
linkedin_mcp_server/tools/messaging.py New module registering inbox/conversation/search/send messaging tools.
linkedin_mcp_server/tools/init.py Updates tool module documentation to mention messaging tools.
linkedin_mcp_server/server.py Registers messaging tools on server startup.
linkedin_mcp_server/scraping/extractor.py Implements sidebar scraping, profile URN extraction, and messaging automation helpers.
README.md Adds get_sidebar_profiles to the tool status table.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 925 to 931
result: dict[str, Any] = {
"url": f"{base_url}/",
"sections": sections,
}
if profile_urn:
result["profile_urn"] = profile_urn
if references:
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrape_person now conditionally adds a profile_urn field to the returned dict (see the new result["profile_urn"] assignment). The method’s docstring still documents only {url, sections} as the return shape; please update it so callers/tests/documentation reflect the optional profile_urn key.

Copilot uses AI. Check for mistakes.

def __init__(self, page: Page):
self._page = page
self._conversation_thread_cache: dict[str, str] = {}
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LinkedInExtractor now has _conversation_thread_cache, but get_ready_extractor() constructs a new LinkedInExtractor(browser.page) for each tool call, so this cache will be empty on every call in normal usage. Consider removing it or moving the cache to a longer-lived place (e.g., session/browser state) if cross-call caching is intended.

Suggested change
self._conversation_thread_cache: dict[str, str] = {}
# Share the conversation thread cache across extractor instances that
# operate on the same Page, so cached data persists between tool calls.
existing_cache = getattr(page, "_conversation_thread_cache", None)
if existing_cache is None:
existing_cache = {}
setattr(page, "_conversation_thread_cache", existing_cache)
self._conversation_thread_cache = existing_cache

Copilot uses AI. Check for mistakes.
await ctx.report_progress(progress=100, total=100, message="Complete")

return result

Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_sidebar_profiles does not handle AuthenticationError the same way the other person tools do. Since LinkedInExtractor navigation can raise AuthenticationError, this tool should catch it and delegate to handle_auth_error(e, ctx) (mirroring get_person_profile/search_people/connect_with_person) so callers get the standard re-login flow instead of a generic ToolError.

Suggested change
except AuthenticationError as e:
try:
await handle_auth_error(e, ctx)
except Exception as relogin_exc:
raise_tool_error(relogin_exc, "get_sidebar_profiles")

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +63
try:
extractor = extractor or await get_ready_extractor(
ctx, tool_name="get_inbox"
)
logger.info("Fetching inbox (limit=%d)", limit)

await ctx.report_progress(
progress=0, total=100, message="Loading messaging inbox"
)

result = await extractor.get_inbox(limit=limit)

await ctx.report_progress(progress=100, total=100, message="Complete")

return result

except Exception as e:
raise_tool_error(e, "get_inbox") # NoReturn
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Messaging tools currently don’t handle AuthenticationError/re-login the way the existing person/company/job tools do. Because extractor navigation can raise AuthenticationError after get_ready_extractor returns, these tools should add an except AuthenticationError as e: await handle_auth_error(e, ctx) branch (and import AuthenticationError + handle_auth_error) so stale sessions trigger the standard re-auth flow.

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +106
_MESSAGING_THREAD_LINK_SELECTOR = 'main a[href*="/messaging/thread/"]'
_MESSAGING_RESULT_ITEM_SELECTOR = "main [role='listitem'], main li"
_MESSAGING_COMPOSE_LINK_SELECTOR = 'main a[href*="/messaging/compose/"]'
_MESSAGING_COMPOSE_SELECTOR = (
'div[role="textbox"][contenteditable="true"][aria-label*="Write a message"]'
)
_MESSAGING_COMPOSE_FALLBACK_SELECTORS = (
_MESSAGING_COMPOSE_SELECTOR,
'main div[role="textbox"][contenteditable="true"]',
'main [contenteditable="true"][aria-label*="message"]',
)
_MESSAGING_SEND_SELECTOR = (
'button[type="submit"], button[aria-label*="Send"], button[aria-label*="send"]'
)
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New messaging selector constants _MESSAGING_THREAD_LINK_SELECTOR, _MESSAGING_RESULT_ITEM_SELECTOR, and _MESSAGING_SEND_SELECTOR are currently unused in this module. Either remove them to avoid dead code, or switch the relevant querySelector/querySelectorAll calls to use them so there’s a single source of truth for these selectors.

Copilot uses AI. Check for mistakes.
Comment on lines +591 to +599
async def _click_first(self, selector: str, *, timeout: int = 5000) -> None:
"""Click the first visible locator that matches a selector."""
target = self._page.locator(selector).first
try:
await target.scroll_into_view_if_needed(timeout=timeout)
except Exception:
logger.debug("Could not scroll %s into view", selector, exc_info=True)
await target.click(timeout=timeout)

Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_click_first’s docstring says it clicks the first visible locator, but the implementation always targets .first without ensuring visibility. Either adjust the implementation to find a visible element (or wait for visible before clicking), or update the docstring to reflect the actual behavior to avoid misleading future changes.

Copilot uses AI. Check for mistakes.
Comment thread README.md
Comment on lines +45 to 46
| `get_sidebar_profiles` | Extract profile URLs from sidebar recommendation sections ("More profiles for you", "Explore premium profiles", "People you may know") on a profile page | Working |
| `get_company_profile` | Extract company information with explicit section selection (posts, jobs) | Working |
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The README tool-status table is meant to enumerate the available MCP tools, but it now omits the newly added messaging tools (get_inbox, get_conversation, search_conversations, send_message). Please add rows for these tools (and brief descriptions) so documentation matches the server/manifest capabilities.

Copilot uses AI. Check for mistakes.
@aspectrr aspectrr changed the title Aspectrr/linkedin connect feat: linkedin messaging, get sidebar profiles Mar 30, 2026
stickerdaniel and others added 4 commits March 30, 2026 15:46
- 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.
@stickerdaniel stickerdaniel merged commit 81c5dc2 into stickerdaniel:main Mar 30, 2026
5 checks passed
stickerdaniel added a commit that referenced this pull request Apr 2, 2026
feat: linkedin messaging, get sidebar profiles
stickerdaniel added a commit that referenced this pull request Apr 2, 2026
feat: linkedin messaging, get sidebar profiles
stickerdaniel added a commit that referenced this pull request Apr 3, 2026
feat: linkedin messaging, get sidebar profiles
Naman-B-Parlecha pushed a commit to Naman-B-Parlecha/linkedin-mcp-server that referenced this pull request Apr 17, 2026
…connect

feat: linkedin messaging, get sidebar profiles
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.

3 participants