feat(messaging): add inbox, conversation, search, and send tools#298
feat(messaging): add inbox, conversation, search, and send tools#298ramonmazinga wants to merge 1 commit into
Conversation
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>
|
Closing — upstream already merged messaging tools in a recent release. Our changes are redundant. Thanks! |
Greptile SummaryThis PR adds four new MCP tools for LinkedIn messaging ( Key concerns in the extractor implementation:
Confidence Score: 4/5
Important Files Changed
Sequence DiagramsequenceDiagram
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
Prompt To Fix All With AIThis 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) |
There was a problem hiding this 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).
| 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)): |
There was a problem hiding this 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.
| 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.| 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") |
There was a problem hiding this 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)
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.| compose_box = self._page.locator(compose_selector).first | ||
| await compose_box.click() | ||
| await compose_box.fill(message) | ||
| await asyncio.sleep(0.5) |
There was a problem hiding this 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:
| 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.
Summary
get_inbox,get_conversation,search_conversations,send_messagesend_messageis annotated withdestructiveHintfor client-side confirmationtools/messaging.pyfollows the existing tool registration patternscraping/extractor.pywith auth barrier handling, rate limit awareness, and fallback navigation strategiesNew Tools
get_inbox(limit)get_conversation(linkedin_username)search_conversations(keywords)send_message(linkedin_username, message)Test plan
get_inboxtested — returns conversation list with participants and last message previewsget_conversation— needs testing with various profile slugssearch_conversations— needs testing with keyword searchsend_message— needs testing with a 1st-degree connectionNotes
get_conversationmethod tries the direct messaging URL first, falls back to navigating to the profile and clicking "Message"