Skip to content

feat(messaging): add inbox, conversation, search, and send tools#298

Closed
ramonmazinga wants to merge 1 commit into
stickerdaniel:mainfrom
ramonmazinga:feat/messaging-tools
Closed

feat(messaging): add inbox, conversation, search, and send tools#298
ramonmazinga wants to merge 1 commit into
stickerdaniel:mainfrom
ramonmazinga:feat/messaging-tools

Conversation

@ramonmazinga
Copy link
Copy Markdown

@ramonmazinga ramonmazinga commented Mar 31, 2026

Summary

  • Adds 4 new MCP tools for LinkedIn messaging: get_inbox, get_conversation, search_conversations, send_message
  • Uses the same innerText extraction pattern as existing person/company/job tools
  • send_message is annotated with destructiveHint for client-side confirmation
  • New tools/messaging.py follows the existing tool registration pattern
  • Extractor methods added to scraping/extractor.py with auth barrier handling, rate limit awareness, and fallback navigation strategies

New Tools

Tool Type Description
get_inbox(limit) Read-only Scrape recent inbox conversations with message previews
get_conversation(linkedin_username) Read-only Full message thread with a specific user
search_conversations(keywords) Read-only Search messaging by keywords
send_message(linkedin_username, message) Destructive Send a DM (requires 1st-degree connection)

Test plan

  • get_inbox tested — returns conversation list with participants and last message previews
  • Server starts without import errors
  • get_conversation — needs testing with various profile slugs
  • search_conversations — needs testing with keyword search
  • send_message — needs testing with a 1st-degree connection

Notes

  • Aware of draft PR feat(messaging): add inbox, conversation, search, and send tools #235 which takes a similar approach — happy to consolidate if preferred
  • The get_conversation method tries the direct messaging URL first, falls back to navigating to the profile and clicking "Message"
  • Scrolling logic loads additional conversations in inbox and older messages in threads

Four new MCP tools for LinkedIn messaging:
- get_inbox: scrape recent conversations with previews
- get_conversation: full message thread with a specific user
- search_conversations: search messaging by keywords
- send_message: send a DM to a 1st-degree connection (destructiveHint)

Uses the same innerText extraction pattern as existing tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@ramonmazinga
Copy link
Copy Markdown
Author

Closing — upstream already merged messaging tools in a recent release. Our changes are redundant. Thanks!

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 31, 2026

Greptile Summary

This PR adds four new MCP tools for LinkedIn messaging (get_inbox, get_conversation, search_conversations, send_message), following the existing tool-registration pattern in tools/messaging.py and extending the LinkedInExtractor class in scraping/extractor.py. The server wiring in server.py is minimal and correct.

Key concerns in the extractor implementation:

  • Missing detect_rate_limit calls (P1): Every existing page-navigation helper in extractor.py calls await detect_rate_limit(self._page) immediately after _navigate_to_page. All four new messaging methods omit this, meaning LinkedIn rate-limit or checkpoint pages will be silently returned as "inbox" or "conversation" content instead of raising a RateLimitError.
  • Class-name selectors violate CLAUDE.md style rule (P2): Multiple selectors use LinkedIn-specific CSS class substrings (msg-conversations-container, msg-s-message-list, msg-form__contenteditable, etc.), which the project guide explicitly prohibits. Structural/ARIA alternatives should be preferred.
  • limit // 10 bottoms out at zero (P2): For any limit < 10, no scroll iterations occur; the parameter also doesn't actually cap the number of returned conversations, only scroll depth.
  • fill() on contenteditable may leave Send button disabled (P2): LinkedIn's compose area is a React-controlled contenteditable div; fill() may not dispatch the events React needs to enable the Send button. Using type() is more reliable.

Confidence Score: 4/5

  • Mostly safe to merge, but the missing detect_rate_limit calls are a real defect that will cause silent failures when LinkedIn rate-limits the messaging endpoints.
  • One clear P1 defect: all four methods skip the project-standard detect_rate_limit check after navigation, meaning rate-limited pages are returned as valid content. This is the same pattern violation that would mask LinkedIn throttling — a common occurrence on messaging endpoints. The remaining findings are P2 (style guide violations on selectors, fill() reliability, scroll-count math) and do not block correctness on the happy path.
  • linkedin_mcp_server/scraping/extractor.py — all four new methods need await detect_rate_limit(self._page) after every _navigate_to_page call.

Important Files Changed

Filename Overview
linkedin_mcp_server/scraping/extractor.py Adds four messaging extractor methods; all are missing the project-standard detect_rate_limit call after _navigate_to_page, and they rely on LinkedIn class-name selectors that the project's style guide explicitly forbids.
linkedin_mcp_server/tools/messaging.py New tool-registration file; correctly follows the existing pattern for auth error handling, progress reporting, exclude_args, and destructiveHint annotation on send_message.
linkedin_mcp_server/server.py Adds the register_messaging_tools import and call; minimal, correct change.

Sequence Diagram

sequenceDiagram
    participant C as MCP Client
    participant T as tools/messaging.py
    participant E as extractor.py
    participant LI as LinkedIn

    C->>T: get_inbox(limit)
    T->>E: extractor.get_inbox(limit=limit)
    E->>LI: _navigate_to_page("/messaging/")
    Note over E: ⚠️ missing detect_rate_limit()
    E->>LI: sidebar.evaluate(scrollTop) ×N
    E->>E: get_page_text() + _extract_root_content()
    E-->>T: {url, sections, references?}
    T-->>C: result

    C->>T: get_conversation(username)
    T->>E: extractor.get_conversation(username)
    E->>LI: _navigate_to_page("/messaging/thread/new/?recipient=…")
    Note over E: ⚠️ missing detect_rate_limit()
    alt thread selector found
        E->>E: scroll thread ×3
    else timeout → fallback
        E->>LI: _navigate_to_page("/in/{username}/")
        E->>LI: click_button_by_text("Message")
    end
    E-->>T: {url, sections, references?}

    C->>T: send_message(username, message)
    T->>E: extractor.send_message(username, message)
    E->>LI: _navigate_to_page("/in/{username}/")
    Note over E: ⚠️ missing detect_rate_limit()
    E->>LI: click_button_by_text("Message")
    E->>LI: compose_box.fill(message)
    Note over E: ⚠️ fill() may not trigger React events
    E->>LI: send_button.click()
    E-->>T: {url, status, message}
    T-->>C: result
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 1376

Comment:
**Missing `detect_rate_limit` calls after navigation**

Every existing scraping helper in this file calls `detect_rate_limit(self._page)` immediately after `_navigate_to_page(url)` (see `_extract_page_once` line 552, `_extract_overlay_page_once` line 666, `_extract_search_page_once` line 1120). All four new messaging methods omit this call entirely.

Without it, if LinkedIn rate-limits or shows a security checkpoint on the messaging URL, the tool will silently return the error page's text (which is typically very short) rather than raising a `RateLimitError`. The caller gets misleading "inbox" content that is actually an error page.

The fix is to add `await detect_rate_limit(self._page)` after each `_navigate_to_page` call across all four methods. In `get_conversation` and `search_conversations` this also applies to the fallback navigation paths (profile URL and the search-URL fallback).

```suggestion
        await self._navigate_to_page(url)
        await detect_rate_limit(self._page)
```

This same fix is needed in `get_conversation` (after both the primary URL at line ~1423 and the profile fallback at line ~1435), in `search_conversations` (after both navigations at lines ~1481 and ~1498), and in `send_message` (after line ~1529).

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: 1391

Comment:
**`limit < 10` produces zero scroll iterations**

`min(limit // 10, 5)` uses integer division, so any `limit` below `10` evaluates to `0` — meaning the sidebar is never scrolled and only the initially-rendered conversations are captured. The default is `20` (2 scrolls), but callers passing `5` or `9` will silently get 0 scrolls.

Additionally, the `limit` value is never used to trim the returned text; all visible conversations are returned regardless of `limit`. The parameter currently only controls scroll depth, not the true count of returned items — which may be worth documenting or fixing.

```suggestion
            for _ in range(min(max(limit // 10, 1), 5)):
```
This ensures at least one scroll occurs for any `limit ≥ 1`.

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: 1379-1395

Comment:
**Class-name selectors violate the project's DOM-dependence rule**

`CLAUDE.md` states: *"Minimize DOM dependence. Prefer innerText and URL navigation over DOM selectors. When DOM access is unavoidable, use minimal generic selectors — **never class names tied to LinkedIn's layout**."*

All four new methods rely heavily on LinkedIn-specific class substrings (`msg-conversations-container`, `list-style-none`, `msg-s-message-list`, `msg-thread`, `msg-search-form`, `msg-form__contenteditable`, `msg-form__send-button`). These will silently break whenever LinkedIn obfuscates or renames its CSS classes (a pattern they follow frequently).

The selectors should be replaced with structural/ARIA alternatives wherever possible (e.g., `[role="list"]`, `[role="textbox"]`, `[aria-label*="…"]`, `button[type="submit"]`). At minimum, the dependency should be noted inline, as the project guideline explicitly calls this class out as a risk.

**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.

---

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

Comment:
**`fill()` on a contenteditable div may not enable the Send button**

LinkedIn's compose area is a React-controlled `contenteditable` div. Playwright's `fill()` sets the element's text content directly (a `beforeinput`/`input`-level operation), but it does not always dispatch the full synthetic event chain that React's controlled components need to update their internal state and mark the form as "dirty" — which is typically what enables the Send button.

If the Send button remains in its initial disabled/inactive state, the `send_button.click()` call will time out (5 s) and the method will return `"send_failed"` even though the text appears in the box.

Using `type()` (which simulates real keystrokes) is more reliable for rich-text/React areas:

```suggestion
        await compose_box.click()
        await compose_box.type(message)
        await asyncio.sleep(0.5)
```

Alternatively, dispatching an `input` event after `fill()` can also unblock the button.

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

Reviews (1): Last reviewed commit: "feat(messaging): add inbox, conversation..." | Re-trigger Greptile

{url, sections: {inbox: raw text}, references?: {...}}
"""
url = "https://www.linkedin.com/messaging/"
await self._navigate_to_page(url)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Missing detect_rate_limit calls after navigation

Every existing scraping helper in this file calls detect_rate_limit(self._page) immediately after _navigate_to_page(url) (see _extract_page_once line 552, _extract_overlay_page_once line 666, _extract_search_page_once line 1120). All four new messaging methods omit this call entirely.

Without it, if LinkedIn rate-limits or shows a security checkpoint on the messaging URL, the tool will silently return the error page's text (which is typically very short) rather than raising a RateLimitError. The caller gets misleading "inbox" content that is actually an error page.

The fix is to add await detect_rate_limit(self._page) after each _navigate_to_page call across all four methods. In get_conversation and search_conversations this also applies to the fallback navigation paths (profile URL and the search-URL fallback).

Suggested change
await self._navigate_to_page(url)
await self._navigate_to_page(url)
await detect_rate_limit(self._page)

This same fix is needed in get_conversation (after both the primary URL at line ~1423 and the profile fallback at line ~1435), in search_conversations (after both navigations at lines ~1481 and ~1498), and in send_message (after line ~1529).

Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 1376

Comment:
**Missing `detect_rate_limit` calls after navigation**

Every existing scraping helper in this file calls `detect_rate_limit(self._page)` immediately after `_navigate_to_page(url)` (see `_extract_page_once` line 552, `_extract_overlay_page_once` line 666, `_extract_search_page_once` line 1120). All four new messaging methods omit this call entirely.

Without it, if LinkedIn rate-limits or shows a security checkpoint on the messaging URL, the tool will silently return the error page's text (which is typically very short) rather than raising a `RateLimitError`. The caller gets misleading "inbox" content that is actually an error page.

The fix is to add `await detect_rate_limit(self._page)` after each `_navigate_to_page` call across all four methods. In `get_conversation` and `search_conversations` this also applies to the fallback navigation paths (profile URL and the search-URL fallback).

```suggestion
        await self._navigate_to_page(url)
        await detect_rate_limit(self._page)
```

This same fix is needed in `get_conversation` (after both the primary URL at line ~1423 and the profile fallback at line ~1435), in `search_conversations` (after both navigations at lines ~1481 and ~1498), and in `send_message` (after line ~1529).

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

'[class*="msg-conversations-container"], [class*="list-style-none"]'
).first
try:
for _ in range(min(limit // 10, 5)):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 limit < 10 produces zero scroll iterations

min(limit // 10, 5) uses integer division, so any limit below 10 evaluates to 0 — meaning the sidebar is never scrolled and only the initially-rendered conversations are captured. The default is 20 (2 scrolls), but callers passing 5 or 9 will silently get 0 scrolls.

Additionally, the limit value is never used to trim the returned text; all visible conversations are returned regardless of limit. The parameter currently only controls scroll depth, not the true count of returned items — which may be worth documenting or fixing.

Suggested change
for _ in range(min(limit // 10, 5)):
for _ in range(min(max(limit // 10, 1), 5)):

This ensures at least one scroll occurs for any limit ≥ 1.

Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 1391

Comment:
**`limit < 10` produces zero scroll iterations**

`min(limit // 10, 5)` uses integer division, so any `limit` below `10` evaluates to `0` — meaning the sidebar is never scrolled and only the initially-rendered conversations are captured. The default is `20` (2 scrolls), but callers passing `5` or `9` will silently get 0 scrolls.

Additionally, the `limit` value is never used to trim the returned text; all visible conversations are returned regardless of `limit`. The parameter currently only controls scroll depth, not the true count of returned items — which may be worth documenting or fixing.

```suggestion
            for _ in range(min(max(limit // 10, 1), 5)):
```
This ensures at least one scroll occurs for any `limit ≥ 1`.

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

Comment on lines +1379 to +1395
await self._page.wait_for_selector(
'[class*="msg-conversations-container"], [class*="messaging"]',
timeout=10000,
)
except PlaywrightTimeoutError:
logger.debug("Messaging container not found, continuing with page text")

# Scroll the conversation list to load more entries
sidebar = self._page.locator(
'[class*="msg-conversations-container"], [class*="list-style-none"]'
).first
try:
for _ in range(min(limit // 10, 5)):
await sidebar.evaluate("el => el.scrollTop = el.scrollHeight")
await asyncio.sleep(1)
except Exception:
logger.debug("Sidebar scroll failed, continuing with loaded content")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Class-name selectors violate the project's DOM-dependence rule

CLAUDE.md states: "Minimize DOM dependence. Prefer innerText and URL navigation over DOM selectors. When DOM access is unavoidable, use minimal generic selectors — never class names tied to LinkedIn's layout."

All four new methods rely heavily on LinkedIn-specific class substrings (msg-conversations-container, list-style-none, msg-s-message-list, msg-thread, msg-search-form, msg-form__contenteditable, msg-form__send-button). These will silently break whenever LinkedIn obfuscates or renames its CSS classes (a pattern they follow frequently).

The selectors should be replaced with structural/ARIA alternatives wherever possible (e.g., [role="list"], [role="textbox"], [aria-label*="…"], button[type="submit"]). At minimum, the dependency should be noted inline, as the project guideline explicitly calls this class out as a risk.

Context Used: CLAUDE.md (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 1379-1395

Comment:
**Class-name selectors violate the project's DOM-dependence rule**

`CLAUDE.md` states: *"Minimize DOM dependence. Prefer innerText and URL navigation over DOM selectors. When DOM access is unavoidable, use minimal generic selectors — **never class names tied to LinkedIn's layout**."*

All four new methods rely heavily on LinkedIn-specific class substrings (`msg-conversations-container`, `list-style-none`, `msg-s-message-list`, `msg-thread`, `msg-search-form`, `msg-form__contenteditable`, `msg-form__send-button`). These will silently break whenever LinkedIn obfuscates or renames its CSS classes (a pattern they follow frequently).

The selectors should be replaced with structural/ARIA alternatives wherever possible (e.g., `[role="list"]`, `[role="textbox"]`, `[aria-label*="…"]`, `button[type="submit"]`). At minimum, the dependency should be noted inline, as the project guideline explicitly calls this class out as a risk.

**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.

Comment on lines +1556 to +1559
compose_box = self._page.locator(compose_selector).first
await compose_box.click()
await compose_box.fill(message)
await asyncio.sleep(0.5)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 fill() on a contenteditable div may not enable the Send button

LinkedIn's compose area is a React-controlled contenteditable div. Playwright's fill() sets the element's text content directly (a beforeinput/input-level operation), but it does not always dispatch the full synthetic event chain that React's controlled components need to update their internal state and mark the form as "dirty" — which is typically what enables the Send button.

If the Send button remains in its initial disabled/inactive state, the send_button.click() call will time out (5 s) and the method will return "send_failed" even though the text appears in the box.

Using type() (which simulates real keystrokes) is more reliable for rich-text/React areas:

Suggested change
compose_box = self._page.locator(compose_selector).first
await compose_box.click()
await compose_box.fill(message)
await asyncio.sleep(0.5)
await compose_box.click()
await compose_box.type(message)
await asyncio.sleep(0.5)

Alternatively, dispatching an input event after fill() can also unblock the button.

Prompt To Fix With AI
This is a comment left during a code review.
Path: linkedin_mcp_server/scraping/extractor.py
Line: 1556-1559

Comment:
**`fill()` on a contenteditable div may not enable the Send button**

LinkedIn's compose area is a React-controlled `contenteditable` div. Playwright's `fill()` sets the element's text content directly (a `beforeinput`/`input`-level operation), but it does not always dispatch the full synthetic event chain that React's controlled components need to update their internal state and mark the form as "dirty" — which is typically what enables the Send button.

If the Send button remains in its initial disabled/inactive state, the `send_button.click()` call will time out (5 s) and the method will return `"send_failed"` even though the text appears in the box.

Using `type()` (which simulates real keystrokes) is more reliable for rich-text/React areas:

```suggestion
        await compose_box.click()
        await compose_box.type(message)
        await asyncio.sleep(0.5)
```

Alternatively, dispatching an `input` event after `fill()` can also unblock the button.

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

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.

1 participant