diff --git a/README.md b/README.md index 766224ab..ebe29be9 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,10 @@ What has Anthropic been posting about recently? https://www.linkedin.com/company | `search_jobs` | Search for jobs with keywords and location filters | Working | | `search_people` | Search for people by keywords and location | Working | | `get_job_details` | Get detailed information about a specific job posting | Working | +| `get_inbox` | List recent conversations from messaging inbox | Working | +| `get_conversation` | Read a specific messaging conversation | Working | +| `search_conversations` | Search messages by keyword | Working | +| `send_message` | Send a message to a LinkedIn user | Working | | `close_session` | Close browser session and clean up resources | Working | Tool responses keep readable `sections` text and may also include a compact `references` map keyed by section. Each reference includes a typed target, a relative LinkedIn path (or absolute external URL), and a short label/context when available. diff --git a/linkedin_mcp_server/scraping/extractor.py b/linkedin_mcp_server/scraping/extractor.py index 42c2e774..ad6224f5 100644 --- a/linkedin_mcp_server/scraping/extractor.py +++ b/linkedin_mcp_server/scraping/extractor.py @@ -1048,6 +1048,265 @@ async def search_people( result["section_errors"] = section_errors return result + # ------------------------------------------------------------------ + # Messaging + # ------------------------------------------------------------------ + + async def get_inbox(self, limit: int = 20) -> dict[str, Any]: + """List recent conversations from the messaging inbox. + + Returns: + {url, sections: {"inbox": text}, references?, section_errors?} + """ + url = "https://www.linkedin.com/messaging/" + await self._goto_with_auth_checks(url) + await detect_rate_limit(self._page) + + try: + await self._page.wait_for_function( + """() => { + const main = document.querySelector('main'); + if (!main) return false; + return main.innerText.length > 100; + }""", + timeout=10000, + ) + except PlaywrightTimeoutError: + logger.debug("Messaging inbox content did not appear") + + await handle_modal_close(self._page) + + # Scroll the conversation list to load more entries + scrolls = max(1, limit // 10) + for _ in range(scrolls): + await self._page.evaluate( + """() => { + const list = document.querySelector( + '.msg-conversations-container__conversations-list' + ) || document.querySelector('main'); + if (list) list.scrollTop = list.scrollHeight; + }""" + ) + await asyncio.sleep(0.5) + + raw_result = await self._extract_root_content(["main"]) + raw = raw_result["text"] + cleaned = strip_linkedin_noise(raw) if raw else "" + + sections: dict[str, str] = {} + references: dict[str, list[Reference]] = {} + section_errors: dict[str, dict[str, Any]] = {} + if cleaned: + sections["inbox"] = cleaned + refs = build_references(raw_result["references"], "inbox") + if refs: + references["inbox"] = refs + + result: dict[str, Any] = {"url": self._page.url, "sections": sections} + if references: + result["references"] = references + if section_errors: + result["section_errors"] = section_errors + return result + + async def get_conversation( + self, + linkedin_username: str | None = None, + thread_id: str | None = None, + ) -> dict[str, Any]: + """Read a specific messaging conversation. + + Provide either ``linkedin_username`` or ``thread_id``. + + Returns: + {url, sections: {"conversation": text}, references?, section_errors?} + """ + if not linkedin_username and not thread_id: + raise ValueError("Provide at least one of linkedin_username or thread_id") + + if thread_id: + url = f"https://www.linkedin.com/messaging/thread/{thread_id}/" + await self._goto_with_auth_checks(url) + elif linkedin_username: + # Navigate to messaging and search for the user + await self._goto_with_auth_checks("https://www.linkedin.com/messaging/") + try: + search_input = self._page.get_by_role("searchbox").first + await search_input.wait_for(timeout=5000) + await search_input.click() + await self._page.keyboard.type(linkedin_username, delay=30) + await asyncio.sleep(1.5) + # Click the first matching conversation + first_result = self._page.locator(".msg-conversation-listitem").first + await first_result.click(timeout=5000) + except PlaywrightTimeoutError: + logger.warning("Could not find conversation for %s", linkedin_username) + + await detect_rate_limit(self._page) + + try: + await self._page.wait_for_function( + """() => { + const main = document.querySelector('main'); + if (!main) return false; + return main.innerText.length > 100; + }""", + timeout=10000, + ) + except PlaywrightTimeoutError: + logger.debug("Conversation content did not appear") + + await handle_modal_close(self._page) + + # Scroll up in the thread to load older messages + for _ in range(3): + await self._page.evaluate( + """() => { + const thread = document.querySelector( + '.msg-s-message-list' + ) || document.querySelector('main'); + if (thread) thread.scrollTop = 0; + }""" + ) + await asyncio.sleep(0.5) + + raw_result = await self._extract_root_content(["main"]) + raw = raw_result["text"] + cleaned = strip_linkedin_noise(raw) if raw else "" + + sections: dict[str, str] = {} + references: dict[str, list[Reference]] = {} + section_errors: dict[str, dict[str, Any]] = {} + if cleaned: + sections["conversation"] = cleaned + refs = build_references(raw_result["references"], "conversation") + if refs: + references["conversation"] = refs + + result: dict[str, Any] = {"url": self._page.url, "sections": sections} + if references: + result["references"] = references + if section_errors: + result["section_errors"] = section_errors + return result + + async def search_conversations(self, keywords: str) -> dict[str, Any]: + """Search messages by keyword. + + Returns: + {url, sections: {"search_results": text}, references?, section_errors?} + """ + url = "https://www.linkedin.com/messaging/" + await self._goto_with_auth_checks(url) + await detect_rate_limit(self._page) + + try: + search_input = self._page.get_by_role("searchbox").first + await search_input.wait_for(timeout=5000) + await search_input.click() + await self._page.keyboard.type(keywords, delay=30) + await asyncio.sleep(1.0) + await self._page.keyboard.press("Enter") + await asyncio.sleep(1.5) + except PlaywrightTimeoutError: + logger.warning("Messaging search input not found") + + try: + await self._page.wait_for_function( + """() => { + const main = document.querySelector('main'); + if (!main) return false; + return main.innerText.length > 100; + }""", + timeout=10000, + ) + except PlaywrightTimeoutError: + logger.debug("Search results content did not appear") + + raw_result = await self._extract_root_content(["main"]) + raw = raw_result["text"] + cleaned = strip_linkedin_noise(raw) if raw else "" + + sections: dict[str, str] = {} + references: dict[str, list[Reference]] = {} + section_errors: dict[str, dict[str, Any]] = {} + if cleaned: + sections["search_results"] = cleaned + refs = build_references(raw_result["references"], "search_results") + if refs: + references["search_results"] = refs + + result: dict[str, Any] = {"url": self._page.url, "sections": sections} + if references: + result["references"] = references + if section_errors: + result["section_errors"] = section_errors + return result + + async def send_message( + self, linkedin_username: str, message: str + ) -> dict[str, Any]: + """Send a message to a LinkedIn user. + + Navigates to the user's profile, opens the message compose box, + types the message, and clicks send. + + Returns: + {url, sections: {"confirmation": text}} + """ + profile_url = f"https://www.linkedin.com/in/{linkedin_username}/" + await self._goto_with_auth_checks(profile_url) + await detect_rate_limit(self._page) + + try: + await self._page.wait_for_selector("main", timeout=5000) + except PlaywrightTimeoutError: + logger.debug("Profile page did not load for %s", linkedin_username) + + await handle_modal_close(self._page) + + # Click the "Message" button on the profile + message_button = self._page.get_by_role("button", name="Message") + try: + await message_button.click(timeout=5000) + except PlaywrightTimeoutError: + raise LinkedInScraperException( + f"Message button not found on {linkedin_username}'s profile. " + "They may not be a 1st-degree connection." + ) + + # Wait for the compose box to appear + compose_box = self._page.locator( + 'div[role="textbox"][contenteditable="true"]' + ).last + try: + await compose_box.wait_for(timeout=5000) + except PlaywrightTimeoutError: + raise LinkedInScraperException("Message compose box did not appear.") + + # Type the message using page.type for contenteditable compatibility + await compose_box.focus() + await self._page.keyboard.type(message, delay=20) + await asyncio.sleep(0.5) + + # Click the send button + send_button = self._page.locator( + 'button[type="submit"], button[aria-label*="Send"], button[aria-label*="send"]' + ).last + try: + await send_button.click(timeout=5000) + except PlaywrightTimeoutError: + raise LinkedInScraperException("Send button not found or not clickable.") + + await asyncio.sleep(1.0) + + return { + "url": self._page.url, + "sections": { + "confirmation": f"Message sent to {linkedin_username}: {message}" + }, + } + async def _extract_root_content( self, selectors: list[str], diff --git a/linkedin_mcp_server/scraping/link_metadata.py b/linkedin_mcp_server/scraping/link_metadata.py index d6029fec..e5ece981 100644 --- a/linkedin_mcp_server/scraping/link_metadata.py +++ b/linkedin_mcp_server/scraping/link_metadata.py @@ -14,6 +14,7 @@ "article", "newsletter", "school", + "messaging", "external", ] @@ -94,6 +95,8 @@ class RawReference(TypedDict, total=False): "search_results": 15, "job_posting": 8, "contact_info": 8, + "inbox": 20, + "conversation": 12, } _URL_LIKE_RE = re.compile(r"^(?:https?://|/)\S+$", re.IGNORECASE) @@ -107,6 +110,7 @@ class RawReference(TypedDict, total=False): _NEWSLETTER_PATH_RE = re.compile(r"^/newsletters/([^/?#]+)") _PULSE_PATH_RE = re.compile(r"^/pulse/([^/?#]+)") _FEED_PATH_RE = re.compile(r"^/feed/update/([^/?#]+)") +_MESSAGING_PATH_RE = re.compile(r"^/messaging/thread/([^/?#]+)") _MAX_REDIRECT_UNWRAP_DEPTH = 5 @@ -229,6 +233,9 @@ def classify_link(href: str) -> tuple[ReferenceKind, str] | None: if match := _FEED_PATH_RE.match(path): return "feed_post", f"/feed/update/{match.group(1)}/" + if match := _MESSAGING_PATH_RE.match(path): + return "messaging", f"/messaging/thread/{match.group(1)}/" + return None @@ -320,6 +327,12 @@ def derive_context( return "company post" return "post attachment" + if section_name == "inbox": + return "conversation" if kind == "messaging" else "participant" + + if section_name == "conversation": + return "participant" if kind == "person" else "message link" + if section_name in {"main_profile", "about"}: if heading in _CONTEXT_LABELS: return heading diff --git a/linkedin_mcp_server/server.py b/linkedin_mcp_server/server.py index 11025d2a..42583ba5 100644 --- a/linkedin_mcp_server/server.py +++ b/linkedin_mcp_server/server.py @@ -20,6 +20,7 @@ ) from linkedin_mcp_server.tools.company import register_company_tools from linkedin_mcp_server.tools.job import register_job_tools +from linkedin_mcp_server.tools.messaging import register_messaging_tools from linkedin_mcp_server.tools.person import register_person_tools logger = logging.getLogger(__name__) @@ -59,6 +60,7 @@ def create_mcp_server() -> FastMCP: register_person_tools(mcp) register_company_tools(mcp) register_job_tools(mcp) + register_messaging_tools(mcp) # Register session management tool @mcp.tool( diff --git a/linkedin_mcp_server/tools/messaging.py b/linkedin_mcp_server/tools/messaging.py new file mode 100644 index 00000000..1736a0dd --- /dev/null +++ b/linkedin_mcp_server/tools/messaging.py @@ -0,0 +1,188 @@ +""" +LinkedIn messaging tools. + +Provides inbox listing, conversation reading, message search, and sending. +""" + +import logging +from typing import Annotated, Any + +from fastmcp import Context, FastMCP +from fastmcp.dependencies import Depends +from pydantic import Field + +from linkedin_mcp_server.constants import TOOL_TIMEOUT_SECONDS +from linkedin_mcp_server.dependencies import get_extractor +from linkedin_mcp_server.error_handler import raise_tool_error +from linkedin_mcp_server.scraping import LinkedInExtractor + +logger = logging.getLogger(__name__) + + +def register_messaging_tools(mcp: FastMCP) -> None: + """Register all messaging-related tools with the MCP server.""" + + @mcp.tool( + timeout=TOOL_TIMEOUT_SECONDS, + title="Get Inbox", + annotations={"readOnlyHint": True, "openWorldHint": True}, + tags={"messaging", "scraping"}, + ) + async def get_inbox( + ctx: Context, + limit: Annotated[int, Field(ge=1, le=50)] = 20, + extractor: LinkedInExtractor = Depends(get_extractor), + ) -> dict[str, Any]: + """ + List recent conversations from the LinkedIn messaging inbox. + + Args: + ctx: FastMCP context for progress reporting + limit: Maximum number of conversations to load (1-50, default 20) + + Returns: + Dict with url, sections (inbox -> raw text), and optional references. + The LLM should parse the raw text to extract conversations. + """ + try: + 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 + + @mcp.tool( + timeout=TOOL_TIMEOUT_SECONDS, + title="Get Conversation", + annotations={"readOnlyHint": True, "openWorldHint": True}, + tags={"messaging", "scraping"}, + ) + async def get_conversation( + ctx: Context, + linkedin_username: str | None = None, + thread_id: str | None = None, + extractor: LinkedInExtractor = Depends(get_extractor), + ) -> dict[str, Any]: + """ + Read a specific messaging conversation. + + Provide either linkedin_username or thread_id to identify the conversation. + + Args: + ctx: FastMCP context for progress reporting + linkedin_username: LinkedIn username of the conversation participant + thread_id: LinkedIn messaging thread ID + + Returns: + Dict with url, sections (conversation -> raw text), and optional references. + The LLM should parse the raw text to extract messages. + """ + try: + logger.info( + "Fetching conversation: username=%s, thread_id=%s", + linkedin_username, + thread_id, + ) + + await ctx.report_progress( + progress=0, total=100, message="Loading conversation" + ) + + result = await extractor.get_conversation( + linkedin_username=linkedin_username, + thread_id=thread_id, + ) + + await ctx.report_progress(progress=100, total=100, message="Complete") + + return result + + except Exception as e: + raise_tool_error(e, "get_conversation") # NoReturn + + @mcp.tool( + timeout=TOOL_TIMEOUT_SECONDS, + title="Search Conversations", + annotations={"readOnlyHint": True, "openWorldHint": True}, + tags={"messaging", "scraping"}, + ) + async def search_conversations( + keywords: str, + ctx: Context, + extractor: LinkedInExtractor = Depends(get_extractor), + ) -> dict[str, Any]: + """ + Search messages by keyword. + + Args: + keywords: Search keywords to filter conversations + ctx: FastMCP context for progress reporting + + Returns: + Dict with url, sections (search_results -> raw text), and optional references. + The LLM should parse the raw text to extract matching conversations. + """ + try: + logger.info("Searching conversations: keywords='%s'", keywords) + + await ctx.report_progress( + progress=0, total=100, message="Searching messages" + ) + + result = await extractor.search_conversations(keywords) + + await ctx.report_progress(progress=100, total=100, message="Complete") + + return result + + except Exception as e: + raise_tool_error(e, "search_conversations") # NoReturn + + @mcp.tool( + timeout=TOOL_TIMEOUT_SECONDS, + title="Send Message", + annotations={"readOnlyHint": False, "openWorldHint": True}, + tags={"messaging"}, + ) + async def send_message( + linkedin_username: str, + message: str, + ctx: Context, + extractor: LinkedInExtractor = Depends(get_extractor), + ) -> dict[str, Any]: + """ + Send a message to a LinkedIn user. + + The recipient must be a 1st-degree connection. This is a write operation + that sends a real message on LinkedIn. + + Args: + linkedin_username: LinkedIn username of the recipient (e.g., "williamhgates") + message: The message text to send + ctx: FastMCP context for progress reporting + + Returns: + Dict with url and sections (confirmation -> sent message text). + """ + try: + logger.info("Sending message to %s", linkedin_username) + + await ctx.report_progress(progress=0, total=100, message="Sending message") + + result = await extractor.send_message(linkedin_username, message) + + await ctx.report_progress(progress=100, total=100, message="Complete") + + return result + + except Exception as e: + raise_tool_error(e, "send_message") # NoReturn diff --git a/manifest.json b/manifest.json index 92b35b65..7d4fca72 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "linkedin-mcp-server", "display_name": "LinkedIn MCP Server", "version": "4.4.1", - "description": "Connect Claude to LinkedIn for profiles, companies, job details, and people search", + "description": "Connect Claude to LinkedIn for profiles, companies, job details, people search, and messaging", "long_description": "# LinkedIn MCP Server\n\nConnect Claude to your LinkedIn account. Access profiles, companies, and job postings through a Docker container on your machine.\n\n## Features\n- **Profile Access**: Get detailed LinkedIn profile information including work history, education, and skills\n- **Company Profiles**: Extract comprehensive company information and details\n- **Job Details**: Retrieve job posting information from LinkedIn URLs\n- **Job Search**: Search for jobs with keywords and location filters\n- **People Search**: Search for people by keywords and location\n- **Company Posts**: Get recent posts from a company's LinkedIn feed\n- **Person Posts**: Get recent activity and posts from a person's profile\n\n## First-Time Setup\n\n### 1. Pre-pull Docker Image (Required)\nRun this command first to avoid connection timeouts:\n```\ndocker pull stickerdaniel/linkedin-mcp-server:4.4.1", "author": { "name": "Daniel Sticker", @@ -14,7 +14,7 @@ "documentation": "https://github.com/stickerdaniel/linkedin-mcp-server#readme", "support": "https://github.com/stickerdaniel/linkedin-mcp-server/issues", "license": "MIT", - "keywords": ["linkedin", "scraping", "mcp", "profiles", "companies", "jobs", "people", "search", "posts"], + "keywords": ["linkedin", "scraping", "mcp", "profiles", "companies", "jobs", "people", "search", "posts", "messaging"], "icon": "assets/icons/linkedin.svg", "screenshots": ["assets/screenshots/screenshot.png"], "server": { @@ -55,6 +55,22 @@ "name": "search_people", "description": "Search for people on LinkedIn by keywords and location" }, + { + "name": "get_inbox", + "description": "List recent conversations from the LinkedIn messaging inbox" + }, + { + "name": "get_conversation", + "description": "Read a specific messaging conversation by username or thread ID" + }, + { + "name": "search_conversations", + "description": "Search messages by keyword" + }, + { + "name": "send_message", + "description": "Send a message to a LinkedIn user" + }, { "name": "close_session", "description": "Properly close browser session and clean up resources" diff --git a/tests/test_link_metadata.py b/tests/test_link_metadata.py index 84d84e2c..7c5ee335 100644 --- a/tests/test_link_metadata.py +++ b/tests/test_link_metadata.py @@ -5,6 +5,7 @@ from linkedin_mcp_server.scraping.link_metadata import ( RawReference, build_references, + classify_link, dedupe_references, normalize_url, ) @@ -497,6 +498,74 @@ def test_keeps_company_about_routes(self): } ] + def test_classifies_messaging_thread_url(self): + result = classify_link("https://www.linkedin.com/messaging/thread/abc123/") + assert result == ("messaging", "/messaging/thread/abc123/") + + def test_classifies_messaging_thread_without_trailing_slash(self): + result = classify_link("https://www.linkedin.com/messaging/thread/abc123") + assert result == ("messaging", "/messaging/thread/abc123/") + + def test_messaging_reference_caps(self): + from linkedin_mcp_server.scraping.link_metadata import _REFERENCE_CAPS + + assert _REFERENCE_CAPS["inbox"] == 20 + assert _REFERENCE_CAPS["conversation"] == 12 + + def test_inbox_context_derivation(self): + refs = build_references( + [ + { + "href": "https://www.linkedin.com/messaging/thread/t1/", + "text": "Chat with Alice", + }, + ], + "inbox", + ) + assert len(refs) == 1 + assert refs[0]["kind"] == "messaging" + assert refs[0]["context"] == "conversation" + + def test_inbox_participant_context(self): + refs = build_references( + [ + { + "href": "https://www.linkedin.com/in/alice/", + "text": "Alice Smith", + }, + ], + "inbox", + ) + assert len(refs) == 1 + assert refs[0]["kind"] == "person" + assert refs[0]["context"] == "participant" + + def test_conversation_participant_context(self): + refs = build_references( + [ + { + "href": "https://www.linkedin.com/in/bob/", + "text": "Bob Jones", + }, + ], + "conversation", + ) + assert len(refs) == 1 + assert refs[0]["context"] == "participant" + + def test_conversation_message_link_context(self): + refs = build_references( + [ + { + "href": "https://example.com/shared-doc", + "text": "Shared Document", + }, + ], + "conversation", + ) + assert len(refs) == 1 + assert refs[0]["context"] == "message link" + def test_cross_page_dedupe_keeps_better_reference(self): references = dedupe_references( [ diff --git a/tests/test_scraping.py b/tests/test_scraping.py index 279086e9..01f21b77 100644 --- a/tests/test_scraping.py +++ b/tests/test_scraping.py @@ -1773,3 +1773,229 @@ async def test_search_results_timeout_proceeds_gracefully(self, mock_page): ) assert result.text == placeholder + + +class TestMessaging: + """Tests for messaging extractor methods.""" + + async def test_get_inbox_navigates_and_extracts(self, mock_page): + mock_page.evaluate = AsyncMock( + return_value={ + "source": "root", + "text": "Conversation with Alice\nConversation with Bob", + "references": [], + } + ) + mock_page.url = "https://www.linkedin.com/messaging/" + extractor = LinkedInExtractor(mock_page) + with ( + patch( + "linkedin_mcp_server.scraping.extractor.detect_rate_limit", + new_callable=AsyncMock, + ), + patch( + "linkedin_mcp_server.scraping.extractor.handle_modal_close", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "linkedin_mcp_server.scraping.extractor.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + result = await extractor.get_inbox(limit=20) + + assert "inbox" in result["sections"] + assert "Alice" in result["sections"]["inbox"] + mock_page.goto.assert_awaited_once() + assert "messaging" in mock_page.goto.call_args[0][0] + + async def test_get_inbox_empty_page(self, mock_page): + mock_page.evaluate = AsyncMock( + return_value={"source": "root", "text": "", "references": []} + ) + mock_page.url = "https://www.linkedin.com/messaging/" + extractor = LinkedInExtractor(mock_page) + with ( + patch( + "linkedin_mcp_server.scraping.extractor.detect_rate_limit", + new_callable=AsyncMock, + ), + patch( + "linkedin_mcp_server.scraping.extractor.handle_modal_close", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "linkedin_mcp_server.scraping.extractor.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + result = await extractor.get_inbox() + + assert result["sections"] == {} + + async def test_get_conversation_by_thread_id(self, mock_page): + mock_page.evaluate = AsyncMock( + return_value={ + "source": "root", + "text": "Alice: Hello\nBob: Hi there", + "references": [], + } + ) + mock_page.url = "https://www.linkedin.com/messaging/thread/abc123/" + extractor = LinkedInExtractor(mock_page) + with ( + patch( + "linkedin_mcp_server.scraping.extractor.detect_rate_limit", + new_callable=AsyncMock, + ), + patch( + "linkedin_mcp_server.scraping.extractor.handle_modal_close", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "linkedin_mcp_server.scraping.extractor.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + result = await extractor.get_conversation(thread_id="abc123") + + assert "conversation" in result["sections"] + assert "abc123" in mock_page.goto.call_args[0][0] + + async def test_get_conversation_requires_identifier(self, mock_page): + extractor = LinkedInExtractor(mock_page) + with pytest.raises(ValueError, match="linkedin_username or thread_id"): + await extractor.get_conversation() + + async def test_search_conversations_types_keywords(self, mock_page): + mock_page.evaluate = AsyncMock( + return_value={ + "source": "root", + "text": "Matching: project update discussion", + "references": [], + } + ) + mock_page.url = "https://www.linkedin.com/messaging/" + + mock_search_input = MagicMock() + mock_search_input.wait_for = AsyncMock() + mock_search_input.click = AsyncMock() + mock_page.get_by_role = MagicMock( + return_value=MagicMock(first=mock_search_input) + ) + + mock_keyboard = MagicMock() + mock_keyboard.type = AsyncMock() + mock_keyboard.press = AsyncMock() + mock_page.keyboard = mock_keyboard + + extractor = LinkedInExtractor(mock_page) + with ( + patch( + "linkedin_mcp_server.scraping.extractor.detect_rate_limit", + new_callable=AsyncMock, + ), + patch( + "linkedin_mcp_server.scraping.extractor.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + result = await extractor.search_conversations("project update") + + assert "search_results" in result["sections"] + mock_search_input.click.assert_awaited_once() + mock_keyboard.type.assert_awaited_once_with("project update", delay=30) + mock_keyboard.press.assert_awaited_once_with("Enter") + + async def test_send_message_interaction_sequence(self, mock_page): + mock_page.url = "https://www.linkedin.com/in/testuser/" + + mock_message_button = MagicMock() + mock_message_button.click = AsyncMock() + + mock_compose_box = MagicMock() + mock_compose_box.wait_for = AsyncMock() + mock_compose_box.focus = AsyncMock() + + mock_send_button = MagicMock() + mock_send_button.click = AsyncMock() + + mock_keyboard = MagicMock() + mock_keyboard.type = AsyncMock() + mock_page.keyboard = mock_keyboard + + mock_page.get_by_role = MagicMock(return_value=mock_message_button) + + locator_call_count = 0 + + def locator_side_effect(selector): + nonlocal locator_call_count + locator_call_count += 1 + if "textbox" in selector or "contenteditable" in selector: + m = MagicMock() + m.last = mock_compose_box + return m + m = MagicMock() + m.last = mock_send_button + return m + + mock_page.locator = MagicMock(side_effect=locator_side_effect) + + extractor = LinkedInExtractor(mock_page) + with ( + patch( + "linkedin_mcp_server.scraping.extractor.detect_rate_limit", + new_callable=AsyncMock, + ), + patch( + "linkedin_mcp_server.scraping.extractor.handle_modal_close", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "linkedin_mcp_server.scraping.extractor.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + result = await extractor.send_message("testuser", "Hello!") + + assert "confirmation" in result["sections"] + assert "testuser" in result["sections"]["confirmation"] + mock_message_button.click.assert_awaited_once() + mock_compose_box.focus.assert_awaited_once() + mock_keyboard.type.assert_awaited_once_with("Hello!", delay=20) + mock_send_button.click.assert_awaited_once() + + async def test_send_message_no_message_button(self, mock_page): + from patchright.async_api import TimeoutError as PlaywrightTimeoutError + + mock_page.url = "https://www.linkedin.com/in/stranger/" + mock_message_button = MagicMock() + mock_message_button.click = AsyncMock( + side_effect=PlaywrightTimeoutError("Timeout") + ) + mock_page.get_by_role = MagicMock(return_value=mock_message_button) + + from linkedin_mcp_server.core.exceptions import LinkedInScraperException + + extractor = LinkedInExtractor(mock_page) + with ( + patch( + "linkedin_mcp_server.scraping.extractor.detect_rate_limit", + new_callable=AsyncMock, + ), + patch( + "linkedin_mcp_server.scraping.extractor.handle_modal_close", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "linkedin_mcp_server.scraping.extractor.asyncio.sleep", + new_callable=AsyncMock, + ), + pytest.raises(LinkedInScraperException, match="Message button not found"), + ): + await extractor.send_message("stranger", "Hi") diff --git a/tests/test_tools.py b/tests/test_tools.py index 857c9a67..62bce5e3 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -25,6 +25,10 @@ def _make_mock_extractor(scrape_result: dict) -> MagicMock: mock.scrape_job = AsyncMock(return_value=scrape_result) mock.search_jobs = AsyncMock(return_value=scrape_result) mock.search_people = AsyncMock(return_value=scrape_result) + mock.get_inbox = AsyncMock(return_value=scrape_result) + mock.get_conversation = AsyncMock(return_value=scrape_result) + mock.search_conversations = AsyncMock(return_value=scrape_result) + mock.send_message = AsyncMock(return_value=scrape_result) mock.extract_page = AsyncMock( return_value=ExtractedSection(text="some text", references=[]) ) @@ -324,6 +328,156 @@ async def test_search_jobs(self, mock_context): assert "pages_visited" not in result +class TestMessagingTools: + async def test_get_inbox(self, mock_context): + expected = { + "url": "https://www.linkedin.com/messaging/", + "sections": {"inbox": "Conversation 1\nConversation 2"}, + } + mock_extractor = _make_mock_extractor(expected) + + from linkedin_mcp_server.tools.messaging import register_messaging_tools + + mcp = FastMCP("test") + register_messaging_tools(mcp) + + tool_fn = await get_tool_fn(mcp, "get_inbox") + result = await tool_fn(mock_context, extractor=mock_extractor) + assert "inbox" in result["sections"] + mock_extractor.get_inbox.assert_awaited_once_with(limit=20) + + async def test_get_inbox_with_limit(self, mock_context): + expected = { + "url": "https://www.linkedin.com/messaging/", + "sections": {"inbox": "Conversations"}, + } + mock_extractor = _make_mock_extractor(expected) + + from linkedin_mcp_server.tools.messaging import register_messaging_tools + + mcp = FastMCP("test") + register_messaging_tools(mcp) + + tool_fn = await get_tool_fn(mcp, "get_inbox") + result = await tool_fn(mock_context, limit=10, extractor=mock_extractor) + assert "inbox" in result["sections"] + mock_extractor.get_inbox.assert_awaited_once_with(limit=10) + + async def test_get_conversation_by_thread_id(self, mock_context): + expected = { + "url": "https://www.linkedin.com/messaging/thread/abc123/", + "sections": {"conversation": "Hello\nHi there"}, + } + mock_extractor = _make_mock_extractor(expected) + + from linkedin_mcp_server.tools.messaging import register_messaging_tools + + mcp = FastMCP("test") + register_messaging_tools(mcp) + + tool_fn = await get_tool_fn(mcp, "get_conversation") + result = await tool_fn( + mock_context, thread_id="abc123", extractor=mock_extractor + ) + assert "conversation" in result["sections"] + mock_extractor.get_conversation.assert_awaited_once_with( + linkedin_username=None, thread_id="abc123" + ) + + async def test_get_conversation_by_username(self, mock_context): + expected = { + "url": "https://www.linkedin.com/messaging/thread/xyz/", + "sections": {"conversation": "Messages here"}, + } + mock_extractor = _make_mock_extractor(expected) + + from linkedin_mcp_server.tools.messaging import register_messaging_tools + + mcp = FastMCP("test") + register_messaging_tools(mcp) + + tool_fn = await get_tool_fn(mcp, "get_conversation") + result = await tool_fn( + mock_context, linkedin_username="testuser", extractor=mock_extractor + ) + assert "conversation" in result["sections"] + mock_extractor.get_conversation.assert_awaited_once_with( + linkedin_username="testuser", thread_id=None + ) + + async def test_get_conversation_requires_identifier(self, mock_context): + mock_extractor = MagicMock() + mock_extractor.get_conversation = AsyncMock( + side_effect=ValueError( + "Provide at least one of linkedin_username or thread_id" + ) + ) + + from linkedin_mcp_server.tools.messaging import register_messaging_tools + + mcp = FastMCP("test") + register_messaging_tools(mcp) + + tool_fn = await get_tool_fn(mcp, "get_conversation") + with pytest.raises(ValueError, match="linkedin_username or thread_id"): + await tool_fn(mock_context, extractor=mock_extractor) + + async def test_search_conversations(self, mock_context): + expected = { + "url": "https://www.linkedin.com/messaging/", + "sections": {"search_results": "Matching conversations"}, + } + mock_extractor = _make_mock_extractor(expected) + + from linkedin_mcp_server.tools.messaging import register_messaging_tools + + mcp = FastMCP("test") + register_messaging_tools(mcp) + + tool_fn = await get_tool_fn(mcp, "search_conversations") + result = await tool_fn("project update", mock_context, extractor=mock_extractor) + assert "search_results" in result["sections"] + mock_extractor.search_conversations.assert_awaited_once_with("project update") + + async def test_send_message(self, mock_context): + expected = { + "url": "https://www.linkedin.com/in/testuser/", + "sections": {"confirmation": "Message sent to testuser: Hello!"}, + } + mock_extractor = _make_mock_extractor(expected) + + from linkedin_mcp_server.tools.messaging import register_messaging_tools + + mcp = FastMCP("test") + register_messaging_tools(mcp) + + tool_fn = await get_tool_fn(mcp, "send_message") + result = await tool_fn( + "testuser", "Hello!", mock_context, extractor=mock_extractor + ) + assert "confirmation" in result["sections"] + mock_extractor.send_message.assert_awaited_once_with("testuser", "Hello!") + + async def test_send_message_error(self, mock_context): + from fastmcp.exceptions import ToolError + + from linkedin_mcp_server.core.exceptions import LinkedInScraperException + + mock_extractor = MagicMock() + mock_extractor.send_message = AsyncMock( + side_effect=LinkedInScraperException("Message button not found") + ) + + from linkedin_mcp_server.tools.messaging import register_messaging_tools + + mcp = FastMCP("test") + register_messaging_tools(mcp) + + tool_fn = await get_tool_fn(mcp, "send_message") + with pytest.raises(ToolError, match="Message button not found"): + await tool_fn("testuser", "Hi", mock_context, extractor=mock_extractor) + + class TestToolTimeouts: async def test_all_tools_have_global_timeout(self): from linkedin_mcp_server.constants import TOOL_TIMEOUT_SECONDS @@ -338,6 +492,10 @@ async def test_all_tools_have_global_timeout(self): "get_company_posts", "get_job_details", "search_jobs", + "get_inbox", + "get_conversation", + "search_conversations", + "send_message", "close_session", )