Contributions are welcome! Packet: search first, then add evidence to an existing issue or prepare a new report. Agents follow the packet skill. Humans use the issue forms.
See the README for full setup instructions.
git clone https://github.com/stickerdaniel/linkedin-mcp-server
cd linkedin-mcp-server
uv sync # Install dependencies
uv sync --group dev # Install dev dependencies
uv run pre-commit install # Set up pre-commit hooks
uv run patchright install chromium # Install browser
uv run pytest --cov # Run tests with coverageThe scraping engine is built around a one-section-one-navigation design. Understanding this is key to contributing effectively.
AI assistants (LLMs) call our MCP tools. Each LinkedIn page navigation takes time. By mapping each section to exactly one URL, the LLM can request only the sections it needs — skipping unnecessary navigations while still capturing all available info from each visited page via innerText extraction.
Section config dicts (scraping/fields.py) define which pages exist:
# Maps section name -> (url_suffix, is_overlay)
PERSON_SECTIONS: dict[str, tuple[str, bool]] = {
"main_profile": ("/", False),
"experience": ("/details/experience/", False),
"contact_info": ("/overlay/contact-info/", True),
"languages": ("/details/languages/", False),
# ...
}The is_overlay boolean distinguishes modal overlays (like contact info) from full page navigations — overlays use a different extraction method that reads from the <dialog> element.
The canonical owner modules iterate the config dict directly, checking which sections the caller requested. scraping/person.py owns person sections and scraping/company.py owns company sections; scraping/extractor.py is only the stable delegating facade. See the generated scraping architecture reference for the current ownership table, import graph, facade surface, and page-owning classification.
for section_name, (suffix, is_overlay) in PERSON_SECTIONS.items():
if section_name not in requested:
continue
# navigate and extract...Return format — all scraping tools return:
{"url": str, "sections": {name: raw_text}}
# Optional compact link metadata:
{"url": str, "sections": {name: raw_text}, "references": {section: [{kind, url, text?, context?, value?}, ...]}}
# When unknown section names are provided:
{"url": str, "sections": {name: raw_text}, "unknown_sections": [name, ...]}
# search_jobs and get_saved_jobs also return:
{"url": str, "sections": {name: raw_text}, "job_ids": [id, ...]}sections remains the main readable payload. references is a compact supplement for entity/article traversal. LinkedIn references are emitted as relative paths to minimize token use.
When adding a section to an existing tool (e.g., adding "certifications" to get_person_profile):
- Add entry to
PERSON_SECTIONSorCOMPANY_SECTIONSwith(url_suffix, is_overlay)(scraping/fields.py) - Add context handling and an explicit reference cap (
scraping/link_metadata.py) - Update tool docstring with new section name (
tools/person.pyortools/company.py)
- Add to
test_expected_keys(tests/test_fields.py) - Add to
test_all_sectionsparse test (tests/test_fields.py) - Update the owner-local all-sections navigation test (
tests/scraping/test_person.pyortests/scraping/test_company.py) - Add the dedicated navigation test beside the canonical owner (
tests/scraping/test_person.pyortests/scraping/test_company.py)
- Update tool table in
README.md - Update features list in
docs/docker-hub.md - Update tools array/description in
manifest.json
-
uv run pytest --cov -
uv run ruff check . --fix && uv run ruff format . -
uv run pre-commit run --all-files
When adding an entirely new MCP tool (e.g., search_companies):
- Add the workflow to its canonical owner module from
docs/scraping-architecture.md; keepLinkedInExtractorinscraping/extractor.pyas a thin delegate only if the stable facade needs a new method - Add or extend tool registration function (
tools/*.py) - Register tools in
create_mcp_server()if new file (server.py)
- Add mock method to
_make_mock_extractor(tests/test_tools.py) - Add tool-level test class/method (
tests/test_tools.py) - Add workflow tests in the canonical owner's matching
tests/scraping/test_<owner>.py; add facade-only contract coverage intests/scraping/test_facade_*.pywhen the delegate surface changes
- Update tool table in
README.md - Update features list in
docs/docker-hub.md - Add tool to
toolsarray inmanifest.json
-
uv run pytest --cov -
uv run ruff check . --fix && uv run ruff format . -
uv run pre-commit run --all-files
The architecture reference is generated from the scraping package's Python AST. Do not edit it by hand. Regenerate it after changing module imports, public owners, the facade coroutine surface, facade construction state, or direct page access:
uv run python scripts/generate_scraping_architecture.py
uv run python scripts/generate_scraping_architecture.py --checkThe semantic traces are canonical review fixtures. The checker compares them by
default and deliberately refuses to overwrite anything under
tests/fixtures/scraping-policy/. Generate a candidate outside that directory,
inspect the full diff, and copy accepted bytes into the fixture tree:
uv run python scripts/check_scraping_policy_traces.py --output ../linkedin-mcp-policy-traces
diff -ru tests/fixtures/scraping-policy/v1 ../linkedin-mcp-policy-traces
cp ../linkedin-mcp-policy-traces/*.json tests/fixtures/scraping-policy/v1/
rm -rf ../linkedin-mcp-policy-traces
uv run python scripts/check_scraping_policy_traces.py --checkThe output path must be absent before generation. Commit fixture changes only when the reviewed policy change is intentional.
- Link the canonical issue for the change. Follow the packet procedure above when adding a report.
- Create a branch:
feature/<issue-number>-<short-description>orfix/<issue-number>-<short-description> - Implement, test, and update docs (see checklists above)
- Open a PR — AI agents review first, then manual review
- PRs are squash-merged into
main, so the PR title becomes the commit subject; commits inside a PR are for review only
This project favours innerText extraction and URL navigation over DOM selectors. LinkedIn's markup changes frequently — class names, data- attributes, and component structure are unstable. Our scraping engine is deliberately built to survive those changes:
- Prefer
innerTextoverquerySelector/ DOM walking for data extraction. - Prefer URL navigation (e.g.
/details/experience/) over clicking UI elements. - When DOM access is unavoidable (e.g. extracting
hrefattributes that don't appear in innerText, finding a scrollable container), keep selectors minimal and generic. Favour tag + attribute patterns (a[href*="/jobs/view/"]) over class names (.jobs-search-results-list). - Never scope queries to layout-specific containers like
.jobs-search-results-list— these break silently when LinkedIn redesigns. Usemainas the broadest acceptable scope. - Document any DOM dependency with a comment explaining why innerText/URL navigation isn't sufficient.
- Commits: conventional commits —
type(scope): subject(see CLAUDE.md for details) - Lint/format:
uv run ruff check . --fix && uv run ruff format . - Type check:
uv run ty check - Tests:
uv run pytest --cov