Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
name: Bug report
about: Something doesn't work the way the docs say it should
title: "[bug] "
labels: bug
---

## What happened

<!-- The actual behavior. Include the exact command and its output. -->

```
$ linkedin --verbose <command-you-ran>
<copy-paste the output here — REDACT cookie values and private message contents>
```

## What you expected

<!-- The behavior the README / docs led you to expect. -->

## Reproduction

<!-- Minimal steps for someone else to hit the same bug. -->

1.
2.
3.

## Environment

- linkedin-cli version: <!-- output of `linkedin --version` -->
- Python: <!-- output of `python --version` -->
- OS: <!-- macOS 14.x / Ubuntu 24.04 / Windows 11 / ... -->
- Output mode: <!-- default text or --json? -->
- Surface: <!-- CLI or MCP server (linkedin-mcp)? -->

## Additional context

<!-- Anything else: prior versions where it worked, related issues. If this is
a Voyager response-shape change, paste the (redacted) --verbose body
preview — that's usually the whole fix. -->

<!-- Please do NOT paste li_at / JSESSIONID cookie values, member URNs from a
real account, or full private message bodies. -->
35 changes: 35 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
name: Feature request
about: Suggest a capability or change
title: "[feature] "
labels: enhancement
---

## What are you trying to do

<!-- Lead with the use case, not the proposed implementation.
"I want my agent to be able to ..." beats "Add an --xyz flag". -->

## Why the current behavior doesn't fit

<!-- What you tried, what blocked you. Pointers to the README / docs section
that should have covered this case but doesn't, if any. -->

## Proposed shape (optional)

<!-- If you already have a CLI surface or MCP tool signature in mind, drop it
here. Otherwise leave blank — we'll figure it out together. -->

```bash
# example invocation
```

## Surface

<!-- Should this land on the CLI, the MCP server, or both? -->

## Terms-of-Service note

<!-- LinkedIn forbids automated use. Features that would encourage bulk
scraping, spam, or mass-messaging are out of scope — describe how your
use case stays within careful, human-like personal use. -->
49 changes: 41 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,56 @@ on:

jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip

- name: Install dependencies
run: pip install -e ".[dev]"

- name: Lint
run: ruff check linkedin_cli/ tests/

# mypy blocking — the pragmatic config in pyproject.toml is at 0 errors
# and intentionally enforced from here on (see CONTRIBUTING.md).
- name: Type check
run: mypy linkedin_cli/

- name: Test
run: pytest tests/ --cov=linkedin_cli --cov-report=xml --cov-report=term

- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12'
uses: codecov/codecov-action@v4
with:
files: ./coverage.xml
continue-on-error: true # Don't block CI if Codecov is down

audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip
python-version: "3.12"

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
pip install pip-audit

- name: Lint with ruff
run: ruff check linkedin_cli/

- name: Run pytest
run: pytest tests/
- name: Run pip-audit
run: pip-audit --strict || true # Report but don't fail (pip own vulns)
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ ENV/
.ruff_cache/
.mypy_cache/
.coverage
.coverage.*
coverage.xml
htmlcov/

# Editors / OS
Expand Down
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Changelog

All notable changes to this project will be documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/).

## [0.2.0] — 2026-06-04

### Added
- **Native MCP server** (`linkedin_cli/mcp_server.py`), exposed as the
`linkedin-mcp` console entry point. Runs over stdio (FastMCP) and lets MCP
clients like Claude Desktop drive LinkedIn directly — no subprocess, no JSON
parsing. One shared `LinkedInClient` is created in the server lifespan; the
server refuses to start (exit code `2`) if no session is stored. Tools:
- Read: `linkedin_profile_get`, `linkedin_search_people`,
`linkedin_search_companies`, `linkedin_connections_list`,
`linkedin_connections_pending`, `linkedin_messages_list`,
`linkedin_auth_status`.
- Write (gated): `linkedin_connections_send`, `linkedin_messages_send` —
each requires an explicit `confirm=True`, supports `dry_run=True`, and is
rate-limited per session (5 actions per target per 5 minutes) on top of the
existing on-disk daily quotas.
- `mcp>=1.2.0` runtime dependency; `pytest-cov` and `mypy` dev dependencies.
- Tests: 28 → 72, all offline (mocked HTTP, no browser, no live LinkedIn
traffic). New `tests/test_mcp_server.py` (21) and `tests/test_smoke.py` (23);
the MCP server module sits at ~80% branch coverage. Coverage now tracked in CI.
- Italian `README.it.md`, a `docs/` folder (AUTHENTICATION, FAQ), CONTRIBUTING.md,
and GitHub issue templates (bug report + feature request).

### Changed
- Project now ships **two** entry points: `linkedin` (CLI) and `linkedin-mcp`
(MCP server). README restructured with badge header, an MCP section, and a
Quality bar.
- CI expanded to a 3-OS × 3-Python matrix (Ubuntu / macOS / Windows × 3.11 /
3.12 / 3.13) plus a dedicated `pip-audit` job. `ruff` now also lints `tests/`,
and `mypy linkedin_cli/` is blocking.
- `pyproject.toml`: added `[tool.mypy]` (pragmatic baseline), `[tool.coverage]`,
and an `sdist` target.

### Security
- `starlette>=1.0.1` pinned directly in project dependencies (PYSEC-2026-161
affects `starlette<1.0.1`, transitively pulled in by the `mcp` SDK with a
loose `>=0.27` constraint).
- Write tools never act without an explicit `confirm=True` from the caller — an
agent cannot send a connection request or a message autonomously.

## [0.1.0] — 2026-05-19

Initial release. See PyPI
[`mayai-linkedin-cli==0.1.0`](https://pypi.org/project/mayai-linkedin-cli/0.1.0/).

- CLI for LinkedIn's internal Voyager API, cookie-based auth captured from a
real browser via Playwright (`linkedin auth login`).
- Commands: `profile get`, `search people`, `search companies`,
`connections list`, `connections pending`, `connections send`,
`messages list`, `messages send`, plus `auth login | status | logout`.
- Normalized-JSON / URN-graph parser with live `queryId` discovery for
people search.
- Jittered request throttling and per-account daily quotas
(`connections`, `messages`, `api_total`), Fernet-encrypted credential store.
102 changes: 102 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Contributing to linkedin-cli

Thanks for your interest. This project is small enough that bug reports
with reproduction steps are as welcome as PRs.

## Setup

```bash
git clone https://github.com/mayai-it/linkedin-cli.git
cd linkedin-cli
python -m venv .venv && source .venv/bin/activate
make dev # pip install -e ".[dev]" + playwright install chromium
make test # pytest
make lint # ruff
make typecheck # mypy linkedin_cli/
```

You only need a real LinkedIn account if you're working on the live Voyager
paths. The test suite runs fully offline against mocks — see "Testing
discipline" below.

## PR checklist

Before opening a PR, please confirm:

- [ ] `ruff check linkedin_cli/ tests/` is clean.
- [ ] `mypy linkedin_cli/` reports `Success: no issues found`.
- [ ] `pytest tests/` is fully green.
- [ ] Coverage on the touched module is not lower than `main`. Run
`pytest --cov=linkedin_cli --cov-report=term tests/` and check the row.
- [ ] If you added a public-facing change, the PR description includes a
`Changelog` line ready to drop into `CHANGELOG.md` under the next version.
- [ ] No new `# type: ignore` without a specific error code and a one-line
motivation in the surrounding comment.
- [ ] No real LinkedIn cookies (`li_at`, `JSESSIONID`), member URNs from a real
account, or scraped private message bodies in the diff or test fixtures.

## Commit convention

We follow the loose [Conventional
Commits](https://www.conventionalcommits.org/) pattern. A few prefixes cover
most changes:

| Prefix | When |
|---|---|
| `feat:` | New user-visible behavior |
| `fix:` | Bug fix |
| `docs:` | README / docs / changelog only |
| `test:` | Test changes only |
| `refactor:` | Code restructure with no behavior change |
| `chore:` | Tooling, deps, CI |
| `security:` | Hardening, vuln fix |

Subject line ≤ 72 chars. Body is optional; when present, explain *why*, not
*what* — the diff already shows the what.

## Testing discipline

**Never send real LinkedIn traffic from tests.** Connection requests and
messages are user-visible actions that count against daily quotas and feed
LinkedIn's anti-abuse heuristics. An accidental live `send` from a test fixture
is a real action you can't take back.

Patterns we enforce in the existing suite:

- The HTTP layer (`LinkedInClient`) is exercised through `unittest.mock` —
no real socket ever opens, no Playwright browser ever launches.
- The MCP tool tests call the tool functions directly with a hand-rolled
context (`SimpleNamespace`) whose `request_context.lifespan_context` carries a
mocked client — the FastMCP server lifespan is never started.
- Write tools (`linkedin_connections_send`, `linkedin_messages_send`) are tested
through their `dry_run=True` and `confirm` gates so no test path can reach a
real POST.
- Fixtures that build a `Credentials` object use obviously-fake cookie values
(e.g. `li_at="x"`, `jsessionid='"ajax:test"'`).

If you genuinely need to verify against the live API, do it manually from your
own shell on a non-primary account — don't commit a test that does it.

## A note on the Voyager API

The endpoints, headers, `queryId` hashes, and response shapes here are
reverse-engineered and undocumented; LinkedIn changes them without notice. When
you fix a breakage:

- Capture the new shape with `--verbose` (it dumps a body preview) and add a
fixture under `tests/fixtures/` so the parser change is covered.
- Note the date and the web `clientVersion` you verified against in the relevant
module (see the header comment in `linkedin_cli/api/endpoints.py`).

## Reporting bugs

Use the [bug report template](.github/ISSUE_TEMPLATE/bug_report.md). Include the
linkedin-cli version (`linkedin --version`), OS + Python version, the exact
command with `--verbose`, and whether you used `--json`. **Redact cookie values
and private message contents.**

## Proposing features

Use the [feature request template](.github/ISSUE_TEMPLATE/feature_request.md).
Lead with the use case — "what are you trying to do" beats "implement X" — and
we'll figure out the shape together.
7 changes: 5 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install dev test lint clean playwright
.PHONY: install dev test lint typecheck clean playwright

PYTHON ?= python3

Expand All @@ -17,7 +17,10 @@ test:
$(PYTHON) -m pytest tests/

lint:
$(PYTHON) -m ruff check linkedin_cli/
$(PYTHON) -m ruff check linkedin_cli/ tests/

typecheck:
$(PYTHON) -m mypy linkedin_cli/

clean:
find . -type d -name __pycache__ -prune -exec rm -rf {} +
Expand Down
Loading
Loading