Skip to content

Commit 139b016

Browse files
Merge pull request #357 from stickerdaniel/04-13-feat_scraping_add_skills_and_projects_sections_to_get_person_profile
feat(scraping): Add skills and projects sections to get_person_profile
2 parents 6649496 + 96005cc commit 139b016

9 files changed

Lines changed: 81 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,14 @@ After the workflow completes, file a PR in the MCP registry to update the versio
7979

8080
Always read [`CONTRIBUTING.md`](CONTRIBUTING.md) before filing an issue or working on this repository.
8181

82-
- Include the model used for code generation in PR descriptions (e.g. "Generated with Claude Opus 4.6")
83-
- Write a short synthetic prompt that would reproduce the PR diff if given to a fresh Claude Code session. Don't copy the user's first message — distill the conversation into a single instruction that captures the full scope of changes. This tells the maintainer what was intended, which is often more useful than reviewing the full diff.
82+
- Write a short synthetic prompt that would reproduce the PR diff if given to a fresh Claude Code session. Don't copy the user's first message — distill the conversation into a single instruction that captures the full scope of changes. This tells the maintainer what was intended, which is often more useful than reviewing the full diff. Use a Markdown blockquote under a `## Synthetic prompt` heading, followed by the model attribution:
83+
```
84+
## Synthetic prompt
85+
86+
> Add `skills` and `projects` sections to `get_person_profile`, following the certifications PR pattern. Update fields, tests, docs, and manifest.
87+
88+
Generated with <model name and version>
89+
```
8490
- When implementing a new feature/fix:
8591
1. Check open issues. If no issue exists, create one following the templates in `.github/ISSUE_TEMPLATE/`. Fill in every section; delete optional sections if not applicable.
8692
2. Branch from `main`: `feature/issue-number-short-description`

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Through this LinkedIn MCP server, AI assistants like Claude can connect to your
3232

3333
| Tool | Description | Status |
3434
|------|-------------|--------|
35-
| `get_person_profile` | Get profile info with explicit section selection (experience, education, interests, honors, languages, certifications, contact_info, posts) | working |
35+
| `get_person_profile` | Get profile info with explicit section selection (experience, education, interests, honors, languages, certifications, skills, projects, contact_info, posts) | working |
3636
| `connect_with_person` | Send a connection request or accept an incoming one, with optional note | [#304](https://github.com/stickerdaniel/linkedin-mcp-server/issues/304) |
3737
| `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 |
3838
| `get_inbox` | List recent conversations from the LinkedIn messaging inbox | working |

docs/docker-hub.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ A Model Context Protocol (MCP) server that connects AI assistants to LinkedIn. A
44

55
## Features
66

7-
- **Profile Access**: Get detailed LinkedIn profile information
7+
- **Profile Access**: Get detailed LinkedIn profile information including experience, education, skills, projects, certifications, and more
88
- **Profile Connections**: Send connection requests or accept incoming ones, with optional notes
99
- **Company Profiles**: Extract comprehensive company data
1010
- **Job Details**: Retrieve job posting information

linkedin_mcp_server/scraping/fields.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
"honors": ("/details/honors/", False),
1414
"languages": ("/details/languages/", False),
1515
"certifications": ("/details/certifications/", False),
16+
"skills": ("/details/skills/", False),
17+
"projects": ("/details/projects/", False),
1618
"contact_info": ("/overlay/contact-info/", True),
1719
"posts": ("/recent-activity/all/", False),
1820
}

linkedin_mcp_server/tools/person.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ async def get_person_profile(
4444
ctx: FastMCP context for progress reporting
4545
sections: Comma-separated list of extra sections to scrape.
4646
The main profile page is always included.
47-
Available sections: experience, education, interests, honors, languages, certifications, contact_info, posts
48-
Examples: "experience,education", "contact_info", "certifications", "honors,languages", "posts"
47+
Available sections: experience, education, interests, honors, languages, certifications, skills, projects, contact_info, posts
48+
Examples: "experience,education", "contact_info", "skills,projects", "honors,languages", "posts"
4949
Default (None) scrapes only the main profile page.
5050
5151
Returns:

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
"tools": [
4848
{
4949
"name": "get_person_profile",
50-
"description": "Get detailed information from a LinkedIn profile including work history, education, certifications, skills, connections, and recent posts"
50+
"description": "Get detailed information from a LinkedIn profile including work history, education, certifications, skills, projects, connections, and recent posts"
5151
},
5252
{
5353
"name": "connect_with_person",

scripts/dump_snapshots.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,12 @@
3232
PERSON_TARGETS: list[tuple[str, str]] = [
3333
(
3434
"williamhgates",
35-
"experience,education,interests,honors,languages,certifications,contact_info",
35+
"experience,education,interests,honors,languages,certifications,skills,projects,contact_info",
36+
),
37+
(
38+
"anistji",
39+
"experience,education,honors,languages,certifications,skills,projects,contact_info",
3640
),
37-
("anistji", "experience,education,honors,languages,certifications,contact_info"),
3841
]
3942

4043
COMPANY_TARGETS: list[tuple[str, str]] = [

tests/test_fields.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ def test_expected_keys(self):
1818
"honors",
1919
"languages",
2020
"certifications",
21+
"skills",
22+
"projects",
2123
"contact_info",
2224
"posts",
2325
}
@@ -89,7 +91,7 @@ def test_baseline_passed_explicitly_not_unknown(self):
8991

9092
def test_all_sections(self):
9193
requested, unknown = parse_person_sections(
92-
"experience,education,interests,honors,languages,certifications,contact_info,posts"
94+
"experience,education,interests,honors,languages,certifications,skills,projects,contact_info,posts"
9395
)
9496
assert requested == set(PERSON_SECTIONS)
9597
assert unknown == []

tests/test_scraping.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,8 @@ async def test_all_sections_visit_all_urls(self, mock_page):
662662
"honors",
663663
"languages",
664664
"certifications",
665+
"skills",
666+
"projects",
665667
"contact_info",
666668
"posts",
667669
}
@@ -688,8 +690,8 @@ async def test_all_sections_visit_all_urls(self, mock_page):
688690
page_urls = [call.args[0] for call in mock_extract.call_args_list]
689691
overlay_urls = [call.args[0] for call in mock_overlay.call_args_list]
690692
all_urls = page_urls + overlay_urls
691-
# 8 full-page sections + 1 overlay (contact_info)
692-
assert len(page_urls) == 8
693+
# 10 full-page sections + 1 overlay (contact_info)
694+
assert len(page_urls) == 10
693695
assert len(overlay_urls) == 1
694696
# Verify each expected suffix was navigated
695697
assert any(u.endswith("/in/testuser/") for u in all_urls)
@@ -699,6 +701,8 @@ async def test_all_sections_visit_all_urls(self, mock_page):
699701
assert any("/details/honors/" in u for u in all_urls)
700702
assert any("/details/languages/" in u for u in all_urls)
701703
assert any("/details/certifications/" in u for u in all_urls)
704+
assert any("/details/skills/" in u for u in all_urls)
705+
assert any("/details/projects/" in u for u in all_urls)
702706
assert any("/overlay/contact-info/" in u for u in overlay_urls)
703707
assert any("/recent-activity/all/" in u for u in all_urls)
704708
assert set(result["sections"]) == all_sections
@@ -755,6 +759,58 @@ async def test_certifications_visits_details_page(self, mock_page):
755759
assert any("/details/certifications/" in url for url in urls)
756760
assert "certifications" in result["sections"]
757761

762+
async def test_skills_visits_details_page(self, mock_page):
763+
extractor = LinkedInExtractor(mock_page)
764+
with (
765+
patch.object(
766+
extractor,
767+
"extract_page",
768+
new_callable=AsyncMock,
769+
return_value=extracted("Python\nData Analysis"),
770+
) as mock_extract,
771+
patch.object(
772+
extractor,
773+
"_extract_overlay",
774+
new_callable=AsyncMock,
775+
return_value=extracted(""),
776+
),
777+
patch(
778+
"linkedin_mcp_server.scraping.extractor.asyncio.sleep",
779+
new_callable=AsyncMock,
780+
),
781+
):
782+
result = await extractor.scrape_person("test-user", {"skills"})
783+
784+
urls = [call.args[0] for call in mock_extract.call_args_list]
785+
assert any("/details/skills/" in url for url in urls)
786+
assert "skills" in result["sections"]
787+
788+
async def test_projects_visits_details_page(self, mock_page):
789+
extractor = LinkedInExtractor(mock_page)
790+
with (
791+
patch.object(
792+
extractor,
793+
"extract_page",
794+
new_callable=AsyncMock,
795+
return_value=extracted("Portfolio Website\nBuilt with React"),
796+
) as mock_extract,
797+
patch.object(
798+
extractor,
799+
"_extract_overlay",
800+
new_callable=AsyncMock,
801+
return_value=extracted(""),
802+
),
803+
patch(
804+
"linkedin_mcp_server.scraping.extractor.asyncio.sleep",
805+
new_callable=AsyncMock,
806+
),
807+
):
808+
result = await extractor.scrape_person("test-user", {"projects"})
809+
810+
urls = [call.args[0] for call in mock_extract.call_args_list]
811+
assert any("/details/projects/" in url for url in urls)
812+
assert "projects" in result["sections"]
813+
758814

759815
class TestDetectConnectionState:
760816
"""Tests for connection state detection from profile text."""

0 commit comments

Comments
 (0)