diff --git a/AGENTS.md b/AGENTS.md index e54ded43..94d20690 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ This is a **LinkedIn MCP (Model Context Protocol) Server** that enables AI assis | `get_job_details` | Get job posting details | | `search_jobs` | Search jobs by keywords and location | | `close_session` | Close browser session and clean up resources | +| `search_people` | Search for people by keywords and location | **Tool Return Format:** diff --git a/README.md b/README.md index e1cd06b7..5ba9e653 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ What has Anthropic been posting about recently? https://www.linkedin.com/company | `get_company_profile` | Extract company information with explicit section selection (posts, jobs) | Working | | `get_company_posts` | Get recent posts from a company's LinkedIn feed | Working | | `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 | | `close_session` | Close browser session and clean up resources | Working | diff --git a/docs/docker-hub.md b/docs/docker-hub.md index 51c3fe01..e122abc5 100644 --- a/docs/docker-hub.md +++ b/docs/docker-hub.md @@ -8,6 +8,7 @@ A Model Context Protocol (MCP) server that connects AI assistants to LinkedIn. A - **Company Profiles**: Extract comprehensive company data - **Job Details**: Retrieve job posting information - **Job Search**: Search for jobs with keywords and location filters +- **People Search**: Search for people by keywords and location - **Company Posts**: Get recent posts from a company's LinkedIn feed ## Quick Start diff --git a/linkedin_mcp_server/scraping/extractor.py b/linkedin_mcp_server/scraping/extractor.py index 10998b88..2a34a397 100644 --- a/linkedin_mcp_server/scraping/extractor.py +++ b/linkedin_mcp_server/scraping/extractor.py @@ -383,3 +383,31 @@ async def search_jobs( "pages_visited": [url], "sections_requested": ["search_results"], } + + async def search_people( + self, + keywords: str, + location: str | None = None, + ) -> dict[str, Any]: + """Search for people and extract the results page. + + Returns: + {url, sections: {name: text}, pages_visited, sections_requested} + """ + params = f"keywords={quote_plus(keywords)}" + if location: + params += f"&location={quote_plus(location)}" + + url = f"https://www.linkedin.com/search/results/people/?{params}" + text = await self.extract_page(url) + + sections: dict[str, str] = {} + if text: + sections["search_results"] = text + + return { + "url": url, + "sections": sections, + "pages_visited": [url], + "sections_requested": ["search_results"], + } diff --git a/linkedin_mcp_server/tools/person.py b/linkedin_mcp_server/tools/person.py index f0caf4d6..56895f88 100644 --- a/linkedin_mcp_server/tools/person.py +++ b/linkedin_mcp_server/tools/person.py @@ -83,3 +83,53 @@ async def get_person_profile( except Exception as e: return handle_tool_error(e, "get_person_profile") + + @mcp.tool( + annotations=ToolAnnotations( + title="Search People", + readOnlyHint=True, + destructiveHint=False, + openWorldHint=True, + ) + ) + async def search_people( + keywords: str, + ctx: Context, + location: str | None = None, + ) -> dict[str, Any]: + """ + Search for people on LinkedIn. + + Args: + keywords: Search keywords (e.g., "software engineer", "recruiter at Google") + ctx: FastMCP context for progress reporting + location: Optional location filter (e.g., "New York", "Remote") + + Returns: + Dict with url, sections (name -> raw text), pages_visited, and sections_requested. + The LLM should parse the raw text to extract individual people and their profiles. + """ + try: + await ensure_authenticated() + + logger.info( + "Searching people: keywords='%s', location='%s'", + keywords, + location, + ) + + browser = await get_or_create_browser() + extractor = LinkedInExtractor(browser.page) + + await ctx.report_progress( + progress=0, total=100, message="Starting people search" + ) + + result = await extractor.search_people(keywords, location) + + await ctx.report_progress(progress=100, total=100, message="Complete") + + return result + + except Exception as e: + return handle_tool_error(e, "search_people") diff --git a/tests/test_tools.py b/tests/test_tools.py index 6d9ee3aa..9f0f1b7f 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -40,6 +40,7 @@ def _make_mock_extractor(scrape_result: dict) -> MagicMock: mock.scrape_company = AsyncMock(return_value=scrape_result) 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.extract_page = AsyncMock(return_value="some text") return mock @@ -128,6 +129,31 @@ async def test_get_person_profile_error(self, mock_context, monkeypatch): result = await tool_fn("test-user", mock_context) assert result["error"] == "session_expired" + async def test_search_people(self, mock_context, patch_tool_deps, monkeypatch): + expected = { + "url": "https://www.linkedin.com/search/results/people/?keywords=AI+engineer&location=New+York", + "sections": {"search_results": "Jane Doe\nAI Engineer at Acme\nNew York"}, + "pages_visited": [ + "https://www.linkedin.com/search/results/people/?keywords=AI+engineer&location=New+York" + ], + "sections_requested": ["search_results"], + } + mock_extractor = _make_mock_extractor(expected) + monkeypatch.setattr( + "linkedin_mcp_server.tools.person.LinkedInExtractor", + lambda *a, **kw: mock_extractor, + ) + + from linkedin_mcp_server.tools.person import register_person_tools + + mcp = FastMCP("test") + register_person_tools(mcp) + + tool_fn = await get_tool_fn(mcp, "search_people") + result = await tool_fn("AI engineer", mock_context, location="New York") + assert "search_results" in result["sections"] + mock_extractor.search_people.assert_awaited_once_with("AI engineer", "New York") + class TestCompanyTools: async def test_get_company_profile(