From 328e9ecc6a780a14442c8c399e01316eec5c66e5 Mon Sep 17 00:00:00 2001 From: DanieleGiovanardi2408 Date: Thu, 4 Jun 2026 09:59:43 +0200 Subject: [PATCH 1/4] feat: add native MCP server (linkedin-mcp) with 9 tools Expose the CLI over stdio via FastMCP: 7 read tools plus 2 gated write tools (connections_send, messages_send) requiring confirm=True, dry_run, and per-session rate limiting on top of the daily quotas. Adds the linkedin-mcp entry point, the mcp>=1.2.0 dep, and a starlette>=1.0.1 pin (PYSEC-2026-161). Bumps version to 0.2.0. --- linkedin_cli/__init__.py | 2 +- linkedin_cli/api/search.py | 3 +- linkedin_cli/mcp_server.py | 406 +++++++++++++++++++++++++++++++++++++ pyproject.toml | 72 ++++++- 4 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 linkedin_cli/mcp_server.py diff --git a/linkedin_cli/__init__.py b/linkedin_cli/__init__.py index ba2f3d1..fadd367 100644 --- a/linkedin_cli/__init__.py +++ b/linkedin_cli/__init__.py @@ -1,3 +1,3 @@ """linkedin-cli — interact with LinkedIn's internal Voyager API from the terminal.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/linkedin_cli/api/search.py b/linkedin_cli/api/search.py index 76c63d3..ef49c61 100644 --- a/linkedin_cli/api/search.py +++ b/linkedin_cli/api/search.py @@ -240,7 +240,8 @@ def _resolve_search_item_entity( 5. Run the result through `_deep_resolve` so any nested `*key` refs get expanded too. """ - actual_item = raw_item.get("item") if isinstance(raw_item.get("item"), dict) else raw_item + _item = raw_item.get("item") + actual_item: dict[str, Any] = _item if isinstance(_item, dict) else raw_item entity_ref = actual_item.get("*entityResult") or actual_item.get("entityResult") entity: Any = None diff --git a/linkedin_cli/mcp_server.py b/linkedin_cli/mcp_server.py new file mode 100644 index 0000000..f892c2f --- /dev/null +++ b/linkedin_cli/mcp_server.py @@ -0,0 +1,406 @@ +"""MCP server exposing linkedin-cli as native tools for AI agents. + +Runs over stdio. A single :class:`~linkedin_cli.api.client.LinkedInClient` is +created once at startup and shared by all tools via the FastMCP lifespan +context. Cookies are loaded from the same on-disk store the CLI uses +(`linkedin auth login`); the server refuses to start if no session is present. + +CRITICAL: never write to stdout — the stdio transport reserves it for the MCP +protocol. All diagnostics go to stderr. + +Safety posture mirrors the rest of the MayAI CLI suite: read tools are free to +call, but the two *write* tools (`linkedin_connections_send`, +`linkedin_messages_send`) require an explicit ``confirm=True`` from the caller, +support ``dry_run=True`` for validation only, and are rate-limited per session +on top of the on-disk daily quotas the underlying API layer already enforces. +""" + +from __future__ import annotations + +import sys +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.fastmcp.exceptions import ToolError +from mcp.server.session import ServerSession + +from linkedin_cli.api import LinkedInAPIError, LinkedInClient +from linkedin_cli.api.connections import ( + list_connections, + list_pending_invitations, + send_invitation, +) +from linkedin_cli.api.messages import list_conversations, send_message +from linkedin_cli.api.profile import get_profile +from linkedin_cli.api.search import search_companies, search_people +from linkedin_cli.auth import Credentials, load_credentials + +# In-process write log used to rate-limit the two write tools within a single +# MCP session. Each entry: {"kind": str, "target": str, "at": float (monotonic)}. +# Module-level on purpose — one MCP session = one process, so this is the right +# scope. Reset for tests via _reset_session_write_log(). +_SESSION_WRITE_LOG: list[dict[str, Any]] = [] +_RATE_LIMIT_WINDOW_SEC = 300 # 5 minutes +_RATE_LIMIT_MAX = 5 # max writes of one kind to one target per window + + +def _reset_session_write_log() -> None: + """Test hook — clears the in-process write log.""" + _SESSION_WRITE_LOG.clear() + + +@dataclass +class AppContext: + creds: Credentials | None + client: LinkedInClient | None + + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + try: + creds = load_credentials() + except RuntimeError as exc: + sys.stderr.write(f"linkedin-mcp: credentials unreadable: {exc}\n") + raise SystemExit(2) from exc + if creds is None: + sys.stderr.write( + "linkedin-mcp: not authenticated — run `linkedin auth login` first\n" + ) + raise SystemExit(2) + + # throttle=True keeps the jittered inter-request delay AND the daily + # quota checks active — an MCP agent should behave at least as carefully + # as a human at the CLI. + client = LinkedInClient(creds, verbose=False, throttle=True) + masked = creds.member_urn or "(member urn not detected)" + sys.stderr.write(f"linkedin-mcp: ready for {masked}\n") + try: + yield AppContext(creds=creds, client=client) + finally: + client.close() + + +mcp = FastMCP("linkedin-cli", lifespan=app_lifespan) + + +def _app(ctx: Context[ServerSession, AppContext]) -> AppContext: + return ctx.request_context.lifespan_context + + +def _require( + ctx: Context[ServerSession, AppContext], +) -> tuple[Credentials, LinkedInClient]: + """Return the live credentials + client, or raise a ToolError. + + The lifespan refuses to start unauthenticated, so in production this never + raises — but tools guard anyway so they can be unit-tested with a stub + context that carries ``client=None``. + """ + app = _app(ctx) + if app.client is None or app.creds is None: + raise ToolError("not authenticated — run `linkedin auth login` first") + return app.creds, app.client + + +def _check_rate_limit(kind: str, target: str) -> None: + """Raise ToolError if `kind`/`target` exceeded the per-session window.""" + now = time.monotonic() + _SESSION_WRITE_LOG[:] = [ + e for e in _SESSION_WRITE_LOG if now - e["at"] < _RATE_LIMIT_WINDOW_SEC + ] + recent = [e for e in _SESSION_WRITE_LOG if e["kind"] == kind and e["target"] == target] + if len(recent) >= _RATE_LIMIT_MAX: + raise ToolError( + f"Rate limit: {_RATE_LIMIT_MAX} {kind} actions to {target} in the " + f"last {_RATE_LIMIT_WINDOW_SEC // 60} minutes. Aborting to prevent " + "accidental duplicates and to keep the account under LinkedIn's " + "anti-abuse radar." + ) + + +def _record_write(kind: str, target: str) -> None: + _SESSION_WRITE_LOG.append({"kind": kind, "target": target, "at": time.monotonic()}) + + +# --------------------------------------------------------------------------- +# Read tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +def linkedin_profile_get( + ctx: Context[ServerSession, AppContext], + username_or_url: str, +) -> dict[str, Any]: + """Fetch a single LinkedIn profile by public id or full URL. + + Args: + username_or_url: A vanity public id (e.g. `mario-rossi-9558832a`) or a + full profile URL (`https://www.linkedin.com/in/mario-rossi-9558832a`). + + Returns: + A dict with the profile's name, headline, location, connections count, + the canonical `profile_id` URN, the numeric `member_id`, and + `profile_url`. Empty fields are omitted. + """ + _, client = _require(ctx) + try: + profile = get_profile(client, username_or_url) + except LinkedInAPIError as exc: + raise ToolError(str(exc)) from exc + return profile.to_dict() + + +@mcp.tool() +def linkedin_search_people( + ctx: Context[ServerSession, AppContext], + query: str, + company: str | None = None, + title: str | None = None, + limit: int = 10, +) -> list[dict[str, Any]]: + """Search LinkedIn for people by name, role, or keyword. + + Args: + query: Free-text query (name and/or keywords). + company: Optional company name; folded into the keyword string so + LinkedIn restricts to people associated with it. + title: Optional job title; folded into the keyword string. + limit: Maximum number of hits to return (default 10). + + Returns: + One dict per person with `profile_id`, `public_id`, `name`, + `headline`, `location`, and `profile_url`. + """ + _, client = _require(ctx) + try: + hits = search_people(client, query, company=company, title=title) + except LinkedInAPIError as exc: + raise ToolError(str(exc)) from exc + rows = [h.to_dict() for h in hits] + return rows[:limit] if limit and limit > 0 else rows + + +@mcp.tool() +def linkedin_search_companies( + ctx: Context[ServerSession, AppContext], + query: str, + limit: int = 10, +) -> list[dict[str, Any]]: + """Search LinkedIn for companies by name. + + Args: + query: Company name or keyword. + limit: Maximum number of hits to return (default 10). + + Returns: + One dict per company hit (name, url, and any other fields the + search-clusters endpoint returns). + """ + _, client = _require(ctx) + try: + hits = search_companies(client, query) + except LinkedInAPIError as exc: + raise ToolError(str(exc)) from exc + return hits[:limit] if limit and limit > 0 else hits + + +@mcp.tool() +def linkedin_connections_list( + ctx: Context[ServerSession, AppContext], + limit: int = 40, +) -> list[dict[str, Any]]: + """List your first-degree connections, newest first. + + The rows are intentionally lean — LinkedIn's connections endpoint does not + inline profile names, so each row carries `connection_urn` and + `connected_at`. Use `linkedin_profile_get` when you need the full profile. + + Args: + limit: Maximum number of connections to return (default 40). + """ + _, client = _require(ctx) + try: + return list_connections(client, limit=limit) + except LinkedInAPIError as exc: + raise ToolError(str(exc)) from exc + + +@mcp.tool() +def linkedin_connections_pending( + ctx: Context[ServerSession, AppContext], +) -> list[dict[str, Any]]: + """List incoming connection invitations awaiting your response. + + Returns: + One dict per pending invitation with the inviter's name, headline, + public id, the invitation id, and the message (if any). + """ + _, client = _require(ctx) + try: + return list_pending_invitations(client) + except LinkedInAPIError as exc: + raise ToolError(str(exc)) from exc + + +@mcp.tool() +def linkedin_messages_list( + ctx: Context[ServerSession, AppContext], +) -> list[dict[str, Any]]: + """List your latest LinkedIn conversations. + + Returns: + One dict per conversation with `conversation_id`, `unread_count`, + `last_activity_at`, the `participants`, and the last message preview. + """ + creds, client = _require(ctx) + try: + convs = list_conversations(client, creds.member_urn) + except LinkedInAPIError as exc: + raise ToolError(str(exc)) from exc + return [c.to_dict() for c in convs] + + +@mcp.tool() +def linkedin_auth_status( + ctx: Context[ServerSession, AppContext], +) -> dict[str, Any]: + """Report whether this server holds a valid LinkedIn session. + + Returns: + Dict with `authenticated` and (when authenticated) the captured + `member_urn`. If this tool returns `authenticated: True` the server is + bound to a session by construction — it would have refused to start + otherwise. + """ + app = _app(ctx) + if app.client is None or app.creds is None: + return {"authenticated": False} + return { + "authenticated": True, + "member_urn": app.creds.member_urn or "(not detected)", + } + + +# --------------------------------------------------------------------------- +# Write tools (gated) +# --------------------------------------------------------------------------- + + +@mcp.tool() +def linkedin_connections_send( + ctx: Context[ServerSession, AppContext], + profile_id: str, + confirm: bool = False, + dry_run: bool = False, +) -> dict[str, Any]: + """Send a connection request to a profile. + + ⚠️ This performs a real, user-visible action and counts against the daily + connections quota (15/day). The agent MUST obtain explicit user consent and + set `confirm=True` before calling. Use `dry_run=True` to validate without + sending. + + Args: + profile_id: A public id (e.g. `mario-rossi-9558832a`) or an + `urn:li:fsd_profile:…` / `urn:li:member:…` URN. Public ids cost one + extra lookup to resolve to a URN. + confirm: Must be True to actually send. The user — not the agent — + should make this decision. + dry_run: If True, return what would happen without contacting LinkedIn. + + Returns: + `{"status": "ok", "profile": }` on success, `"already-invited"` if + LinkedIn replies 409, or `{"status": "dry-run", ...}` in dry-run mode. + """ + if not profile_id.strip(): + raise ToolError("`profile_id` must not be empty") + + if dry_run: + return {"status": "dry-run", "profile": profile_id, "validated": True} + + if not confirm: + raise ToolError( + "Sending a connection request is a real action that the recipient " + "sees and that counts against your daily quota. Set confirm=True to " + "proceed — this should be confirmed by the user, not the agent " + "autonomously." + ) + + _check_rate_limit("connection", profile_id) + _, client = _require(ctx) + try: + result = send_invitation(client, profile_id) + except LinkedInAPIError as exc: + raise ToolError(str(exc)) from exc + _record_write("connection", profile_id) + return result + + +@mcp.tool() +def linkedin_messages_send( + ctx: Context[ServerSession, AppContext], + recipient: str, + text: str, + confirm: bool = False, + dry_run: bool = False, +) -> dict[str, Any]: + """Send a 1:1 LinkedIn message. + + ⚠️ This sends a real message and counts against the daily messages quota + (25/day). The agent MUST obtain explicit user consent and set `confirm=True` + before calling. Use `dry_run=True` to validate without sending. + + Args: + recipient: A numeric member id or an `urn:li:member:…` URN. A public id + is NOT accepted here — resolve it first with `linkedin_profile_get` + and use the returned `member_id`. + text: The message body (must not be empty). + confirm: Must be True to actually send. The user — not the agent — + should make this decision. + dry_run: If True, return what would happen without contacting LinkedIn. + + Returns: + `{"status": "ok", "recipient": , "conversation_id": …}` on success, + or `{"status": "dry-run", ...}` in dry-run mode. + """ + if not recipient.strip(): + raise ToolError("`recipient` must not be empty") + if not text.strip(): + raise ToolError("`text` must not be empty") + + if dry_run: + return { + "status": "dry-run", + "recipient": recipient, + "length": len(text), + "validated": True, + } + + if not confirm: + raise ToolError( + "Sending a message is a real action the recipient sees. Set " + "confirm=True to proceed — this should be confirmed by the user, " + "not the agent autonomously." + ) + + _check_rate_limit("message", recipient) + _, client = _require(ctx) + try: + result = send_message(client, recipient, text) + except LinkedInAPIError as exc: + raise ToolError(str(exc)) from exc + _record_write("message", recipient) + return result + + +def main() -> None: + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 30ab9bb..47e745d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "mayai-linkedin-cli" -version = "0.1.0" -description = "CLI for LinkedIn (internal Voyager API) — built for AI agents and developers" +version = "0.2.0" +description = "CLI + MCP server for LinkedIn (internal Voyager API) — built for AI agents and developers" readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } @@ -14,11 +14,14 @@ maintainers = [{ name = "MayAI", email = "info@mayai.it" }] keywords = [ "linkedin", "cli", + "command-line", "ai-agents", + "llm", + "mcp", "voyager", "automation", - "scraping", "playwright", + "italy", ] classifiers = [ "Development Status :: 3 - Alpha", @@ -26,6 +29,8 @@ classifiers = [ "Intended Audience :: Developers", "Intended Audience :: Information Technology", "License :: OSI Approved :: MIT License", + "Natural Language :: English", + "Natural Language :: Italian", "Operating System :: MacOS", "Operating System :: POSIX :: Linux", "Operating System :: Microsoft :: Windows", @@ -33,6 +38,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3 :: Only", "Topic :: Internet", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Utilities", @@ -42,22 +48,30 @@ dependencies = [ "click>=8.1.0", "cryptography>=42.0.0", "httpx>=0.27.0", + "mcp>=1.2.0", "playwright>=1.40.0", + # mcp pulls starlette transitively (>=0.27). starlette<1.0.1 has + # PYSEC-2026-161 — pin direct so the resolver picks a non-vulnerable version. + "starlette>=1.0.1", ] [project.optional-dependencies] dev = [ "build>=1.2.0", + "mypy>=1.8.0", "pytest>=8.0.0", + "pytest-cov>=5.0.0", "ruff>=0.5.0", "twine>=5.0.0", ] [project.scripts] linkedin = "linkedin_cli.main:cli" +linkedin-mcp = "linkedin_cli.mcp_server:main" [project.urls] Homepage = "https://mayai.it" +Documentation = "https://github.com/mayai-it/linkedin-cli#readme" Repository = "https://github.com/mayai-it/linkedin-cli" Issues = "https://github.com/mayai-it/linkedin-cli/issues" Changelog = "https://github.com/mayai-it/linkedin-cli/releases" @@ -65,6 +79,17 @@ Changelog = "https://github.com/mayai-it/linkedin-cli/releases" [tool.hatch.build.targets.wheel] packages = ["linkedin_cli"] +[tool.hatch.build.targets.sdist] +include = [ + "linkedin_cli", + "tests", + "README.md", + "README.it.md", + "LICENSE", + "pyproject.toml", + "Makefile", +] + [tool.ruff] line-length = 100 target-version = "py311" @@ -74,3 +99,44 @@ select = ["E", "F", "I", "N", "W", "UP"] [tool.pytest.ini_options] testpaths = ["tests"] + +[tool.mypy] +python_version = "3.11" +warn_unused_configs = true +warn_redundant_casts = true +warn_unused_ignores = true + +# Pragmatic baseline. The API layer is a reverse-engineered parser over +# LinkedIn's normalized JSON — it traffics in `dict[str, Any]` by nature, so a +# fully strict config would be noise. We enforce a sane floor here and tighten +# over time (see CONTRIBUTING.md). What we DO enforce: +check_untyped_defs = true +no_implicit_optional = true +strict_equality = true +extra_checks = true + +# click decorators (@click.command/@click.option) aren't fully typed upstream, +# and the Voyager parser leans on Any-typed generics — requiring either here +# would drown out real findings. +disallow_untyped_decorators = false +disallow_any_generics = false + +# Third-party libraries without bundled stubs. +[[tool.mypy.overrides]] +module = ["mcp.*", "playwright.*"] +ignore_missing_imports = true + +[tool.coverage.run] +source = ["linkedin_cli"] +branch = true +omit = ["*/tests/*", "*/__init__.py"] + +[tool.coverage.report] +precision = 1 +show_missing = true +exclude_lines = [ + "pragma: no cover", + "raise NotImplementedError", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] From fb323222b36544fa47527e2e086ebc834457f78a Mon Sep 17 00:00:00 2001 From: DanieleGiovanardi2408 Date: Thu, 4 Jun 2026 09:59:50 +0200 Subject: [PATCH 2/4] test: add MCP server and smoke suites (28 -> 72 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All offline against mocks — no live LinkedIn traffic, no browser launch. Also wraps two over-length lines in test_profile.py for the new ruff check on tests/. --- tests/test_mcp_server.py | 260 +++++++++++++++++++++++++++++++++++++++ tests/test_profile.py | 10 +- tests/test_smoke.py | 162 ++++++++++++++++++++++++ 3 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 tests/test_mcp_server.py create mode 100644 tests/test_smoke.py diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..7052de2 --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,260 @@ +"""MCP tool tests. + +The ``@mcp.tool()`` decorator registers the function with FastMCP but returns +the original callable unchanged — we exercise the tool logic by calling those +functions directly with a hand-rolled Context whose +``request_context.lifespan_context`` exposes a real ``AppContext``. The +underlying API functions are monkeypatched on the ``mcp_server`` module, so no +HTTP request and no Playwright browser is ever touched. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest +from mcp.server.fastmcp.exceptions import ToolError + +from linkedin_cli import mcp_server +from linkedin_cli.api import LinkedInAPIError +from linkedin_cli.auth import Credentials +from linkedin_cli.mcp_server import ( + AppContext, + _reset_session_write_log, + linkedin_auth_status, + linkedin_connections_list, + linkedin_connections_pending, + linkedin_connections_send, + linkedin_messages_list, + linkedin_messages_send, + linkedin_profile_get, + linkedin_search_companies, + linkedin_search_people, +) +from linkedin_cli.models.profile import Conversation, Profile, SearchHit + +CREDS = Credentials(li_at="x", jsessionid='"ajax:test"', member_urn="urn:li:fsd_profile:ME") + + +def _ctx(client: Any = object(), creds: Credentials | None = CREDS) -> Any: + """Build the minimal Context surface the tools actually read.""" + return SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=AppContext(creds=creds, client=client), + ), + ) + + +@pytest.fixture(autouse=True) +def _clean_write_log() -> None: + """Isolate the module-level per-session write log between tests.""" + _reset_session_write_log() + + +# --------------------------------------------------------------------------- +# Read tools +# --------------------------------------------------------------------------- + + +def test_profile_get_returns_dict(monkeypatch) -> None: + monkeypatch.setattr( + mcp_server, + "get_profile", + lambda client, u: Profile(public_id="johndoe", name="John Doe", headline="CTO"), + ) + out = linkedin_profile_get(_ctx(), username_or_url="johndoe") + assert out["name"] == "John Doe" + assert out["headline"] == "CTO" + + +def test_search_people_applies_limit(monkeypatch) -> None: + hits = [SearchHit(public_id=f"p{i}", name=f"P {i}") for i in range(5)] + monkeypatch.setattr( + mcp_server, "search_people", lambda client, q, company=None, title=None: hits + ) + out = linkedin_search_people(_ctx(), query="x", limit=2) + assert len(out) == 2 + assert out[0]["public_id"] == "p0" + + +def test_search_people_passes_filters(monkeypatch) -> None: + captured: dict[str, Any] = {} + + def _fake(client, q, company=None, title=None): + captured.update(query=q, company=company, title=title) + return [] + + monkeypatch.setattr(mcp_server, "search_people", _fake) + linkedin_search_people(_ctx(), query="eng", company="MayAI", title="Senior") + assert captured == {"query": "eng", "company": "MayAI", "title": "Senior"} + + +def test_search_companies_applies_limit(monkeypatch) -> None: + rows = [{"name": f"C{i}"} for i in range(3)] + monkeypatch.setattr(mcp_server, "search_companies", lambda client, q: rows) + out = linkedin_search_companies(_ctx(), query="x", limit=2) + assert len(out) == 2 + + +def test_connections_list_passthrough(monkeypatch) -> None: + rows = [{"connection_urn": "urn:li:fsd_connection:1", "connected_at": "2025-09-14"}] + monkeypatch.setattr(mcp_server, "list_connections", lambda client, limit=40: rows) + assert linkedin_connections_list(_ctx(), limit=10) == rows + + +def test_connections_pending_passthrough(monkeypatch) -> None: + rows = [{"from_name": "Mario Rossi", "invitation_id": "urn:li:invitation:1"}] + monkeypatch.setattr(mcp_server, "list_pending_invitations", lambda client: rows) + assert linkedin_connections_pending(_ctx()) == rows + + +def test_messages_list_uses_member_urn(monkeypatch) -> None: + captured: dict[str, Any] = {} + + def _fake(client, urn): + captured["urn"] = urn + return [Conversation(conversation_id="urn:li:msg_conversation:1", unread_count=2)] + + monkeypatch.setattr(mcp_server, "list_conversations", _fake) + out = linkedin_messages_list(_ctx()) + assert captured["urn"] == "urn:li:fsd_profile:ME" + assert out[0]["unread_count"] == 2 + + +# --------------------------------------------------------------------------- +# auth_status +# --------------------------------------------------------------------------- + + +def test_auth_status_authenticated() -> None: + out = linkedin_auth_status(_ctx()) + assert out["authenticated"] is True + assert out["member_urn"] == "urn:li:fsd_profile:ME" + + +def test_auth_status_not_authenticated() -> None: + out = linkedin_auth_status(_ctx(client=None, creds=None)) + assert out == {"authenticated": False} + + +# --------------------------------------------------------------------------- +# Error / auth guards +# --------------------------------------------------------------------------- + + +def test_read_tools_raise_when_unauthenticated() -> None: + ctx = _ctx(client=None, creds=None) + with pytest.raises(ToolError, match="not authenticated"): + linkedin_profile_get(ctx, username_or_url="johndoe") + with pytest.raises(ToolError, match="not authenticated"): + linkedin_connections_list(ctx) + + +def test_api_error_translated_to_tool_error(monkeypatch) -> None: + def _boom(client, u): + raise LinkedInAPIError("rate limited by LinkedIn", status=429) + + monkeypatch.setattr(mcp_server, "get_profile", _boom) + with pytest.raises(ToolError, match="rate limited"): + linkedin_profile_get(_ctx(), username_or_url="johndoe") + + +# --------------------------------------------------------------------------- +# Write tool: connections_send +# --------------------------------------------------------------------------- + + +def test_connections_send_dry_run_does_not_call_api(monkeypatch) -> None: + called = False + + def _send(client, pid): + nonlocal called + called = True + return {"status": "ok"} + + monkeypatch.setattr(mcp_server, "send_invitation", _send) + out = linkedin_connections_send(_ctx(), profile_id="johndoe", dry_run=True) + assert out["status"] == "dry-run" + assert called is False + + +def test_connections_send_requires_confirm(monkeypatch) -> None: + monkeypatch.setattr(mcp_server, "send_invitation", lambda c, p: {"status": "ok"}) + with pytest.raises(ToolError, match="confirm=True"): + linkedin_connections_send(_ctx(), profile_id="johndoe") + + +def test_connections_send_empty_profile_raises() -> None: + with pytest.raises(ToolError, match="must not be empty"): + linkedin_connections_send(_ctx(), profile_id=" ", confirm=True) + + +def test_connections_send_confirmed_calls_api(monkeypatch) -> None: + monkeypatch.setattr( + mcp_server, "send_invitation", lambda c, p: {"status": "ok", "profile": p} + ) + out = linkedin_connections_send(_ctx(), profile_id="urn:li:member:1", confirm=True) + assert out == {"status": "ok", "profile": "urn:li:member:1"} + + +def test_connections_send_rate_limited(monkeypatch) -> None: + monkeypatch.setattr(mcp_server, "send_invitation", lambda c, p: {"status": "ok"}) + for _ in range(mcp_server._RATE_LIMIT_MAX): + linkedin_connections_send(_ctx(), profile_id="johndoe", confirm=True) + with pytest.raises(ToolError, match="Rate limit"): + linkedin_connections_send(_ctx(), profile_id="johndoe", confirm=True) + + +# --------------------------------------------------------------------------- +# Write tool: messages_send +# --------------------------------------------------------------------------- + + +def test_messages_send_dry_run_does_not_call_api(monkeypatch) -> None: + called = False + + def _send(client, r, t): + nonlocal called + called = True + return {"status": "ok"} + + monkeypatch.setattr(mcp_server, "send_message", _send) + out = linkedin_messages_send(_ctx(), recipient="12345678", text="ciao", dry_run=True) + assert out["status"] == "dry-run" + assert out["length"] == 4 + assert called is False + + +def test_messages_send_requires_confirm(monkeypatch) -> None: + monkeypatch.setattr(mcp_server, "send_message", lambda c, r, t: {"status": "ok"}) + with pytest.raises(ToolError, match="confirm=True"): + linkedin_messages_send(_ctx(), recipient="12345678", text="ciao") + + +def test_messages_send_empty_text_raises() -> None: + with pytest.raises(ToolError, match="`text` must not be empty"): + linkedin_messages_send(_ctx(), recipient="12345678", text=" ", confirm=True) + + +def test_messages_send_confirmed_calls_api(monkeypatch) -> None: + monkeypatch.setattr( + mcp_server, + "send_message", + lambda c, r, t: {"status": "ok", "recipient": r, "conversation_id": "urn:li:c:1"}, + ) + out = linkedin_messages_send( + _ctx(), recipient="urn:li:member:1", text="ciao", confirm=True + ) + assert out["status"] == "ok" + assert out["recipient"] == "urn:li:member:1" + + +def test_write_tools_raise_when_unauthenticated(monkeypatch) -> None: + monkeypatch.setattr(mcp_server, "send_invitation", lambda c, p: {"status": "ok"}) + monkeypatch.setattr(mcp_server, "send_message", lambda c, r, t: {"status": "ok"}) + ctx = _ctx(client=None, creds=None) + with pytest.raises(ToolError, match="not authenticated"): + linkedin_connections_send(ctx, profile_id="johndoe", confirm=True) + with pytest.raises(ToolError, match="not authenticated"): + linkedin_messages_send(ctx, recipient="12345678", text="ciao", confirm=True) diff --git a/tests/test_profile.py b/tests/test_profile.py index b74c007..27ca6cf 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -11,8 +11,14 @@ "raw, expected", [ ("daniele-giovanardi-282462390", "daniele-giovanardi-282462390"), - ("https://www.linkedin.com/in/daniele-giovanardi-282462390", "daniele-giovanardi-282462390"), - ("https://www.linkedin.com/in/daniele-giovanardi-282462390/", "daniele-giovanardi-282462390"), + ( + "https://www.linkedin.com/in/daniele-giovanardi-282462390", + "daniele-giovanardi-282462390", + ), + ( + "https://www.linkedin.com/in/daniele-giovanardi-282462390/", + "daniele-giovanardi-282462390", + ), ( "https://www.linkedin.com/in/daniele-giovanardi-282462390?utm_source=share", "daniele-giovanardi-282462390", diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..d1f5dcc --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,162 @@ +"""Smoke tests — pure-function helpers that need no live LinkedIn session.""" + +from __future__ import annotations + +import pytest + +from linkedin_cli import __version__ +from linkedin_cli.api import LinkedInAPIError +from linkedin_cli.api.messages import _to_member_urn +from linkedin_cli.api.profile import _extract_username +from linkedin_cli.api.quotas import ( + DEFAULT_LIMITS, + QuotaExceededError, + QuotaLimits, + check_and_increment, + snapshot, +) +from linkedin_cli.auth.credentials import Credentials, normalize_csrf +from linkedin_cli.main import _mask +from linkedin_cli.models.profile import Conversation, Profile, SearchHit + +# --------------------------------------------------------------------------- +# Version / packaging +# --------------------------------------------------------------------------- + + +def test_version_is_exposed() -> None: + assert __version__ == "0.2.0" + + +# --------------------------------------------------------------------------- +# CSRF normalization +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "raw,expected", + [ + ('"ajax:1234567890"', "ajax:1234567890"), + ("ajax:1234567890", "ajax:1234567890"), + (' "ajax:42" ', "ajax:42"), + ('"', '"'), # too short to be a quoted pair — left untouched + ], +) +def test_normalize_csrf_strips_surrounding_quotes(raw: str, expected: str) -> None: + assert normalize_csrf(raw) == expected + + +def test_credentials_csrf_token_property() -> None: + creds = Credentials(li_at="x", jsessionid='"ajax:99"') + assert creds.csrf_token == "ajax:99" + + +# --------------------------------------------------------------------------- +# Profile public-id extraction +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected", + [ + ("mario-rossi-9558832a", "mario-rossi-9558832a"), + ("https://www.linkedin.com/in/mario-rossi-9558832a", "mario-rossi-9558832a"), + ("https://www.linkedin.com/in/mario-rossi-9558832a/", "mario-rossi-9558832a"), + ("linkedin.com/in/johndoe?originalSubdomain=it", "johndoe"), + ], +) +def test_extract_username(value: str, expected: str) -> None: + assert _extract_username(value) == expected + + +# --------------------------------------------------------------------------- +# Messaging recipient -> member URN +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected", + [ + ("12345678", "urn:li:member:12345678"), + ("urn:li:member:12345678", "urn:li:member:12345678"), + ("urn:li:fsd_profile:12345678", "urn:li:member:12345678"), + ], +) +def test_to_member_urn_accepts_numeric_and_urns(value: str, expected: str) -> None: + assert _to_member_urn(value) == expected + + +def test_to_member_urn_rejects_public_id() -> None: + with pytest.raises(LinkedInAPIError, match="public id"): + _to_member_urn("mariorossi") + + +# --------------------------------------------------------------------------- +# Secret masking +# --------------------------------------------------------------------------- + + +def test_mask_hides_middle() -> None: + assert _mask("AQEDARxxxxxxYYYY") == "AQED…YYYY" + + +def test_mask_short_value_fully_hidden() -> None: + assert _mask("short") == "***" + + +def test_mask_empty() -> None: + assert _mask("") == "" + + +# --------------------------------------------------------------------------- +# Model to_dict() drops empty fields +# --------------------------------------------------------------------------- + + +def test_profile_to_dict_omits_empty_fields() -> None: + p = Profile(public_id="johndoe", name="John Doe") + d = p.to_dict() + assert d == {"public_id": "johndoe", "name": "John Doe"} + assert "headline" not in d + + +def test_search_hit_to_dict_omits_empty_fields() -> None: + h = SearchHit(public_id="johndoe", name="John Doe") + assert h.to_dict() == {"public_id": "johndoe", "name": "John Doe"} + + +def test_conversation_to_dict_keeps_unread_count_zero() -> None: + c = Conversation(conversation_id="urn:li:msg_conversation:1") + d = c.to_dict() + # unread_count is explicitly kept even when 0 (it's meaningful). + assert d["unread_count"] == 0 + assert d["conversation_id"] == "urn:li:msg_conversation:1" + + +# --------------------------------------------------------------------------- +# Quotas (offline, tmp-backed) +# --------------------------------------------------------------------------- + + +def test_quota_increments_then_blocks(tmp_path) -> None: + path = tmp_path / "quotas.json" + limits = QuotaLimits(connections=2, messages=25, api_total=200) + + s1 = check_and_increment("connections", limits=limits, path=path) + assert s1["connections"] == 1 + s2 = check_and_increment("connections", limits=limits, path=path) + assert s2["connections"] == 2 + + with pytest.raises(QuotaExceededError): + check_and_increment("connections", limits=limits, path=path) + + +def test_quota_unknown_kind_raises(tmp_path) -> None: + with pytest.raises(ValueError, match="unknown quota kind"): + check_and_increment("nope", path=tmp_path / "q.json") + + +def test_snapshot_reports_defaults(tmp_path) -> None: + snap = snapshot(path=tmp_path / "missing.json") + assert snap["connections"]["limit"] == DEFAULT_LIMITS.connections + assert snap["connections"]["used"] == 0 From 105a521a125137d2ca28aff25199b82904b756c1 Mon Sep 17 00:00:00 2001 From: DanieleGiovanardi2408 Date: Thu, 4 Jun 2026 09:59:55 +0200 Subject: [PATCH 3/4] chore: align CI and tooling with sister repos 3-OS x 3-Python matrix, blocking mypy, coverage reporting, and a pip-audit job. ruff now lints tests/ too; ignore parallel .coverage.* and coverage.xml. --- .github/workflows/ci.yml | 49 +++++++++++++++++++++++++++++++++------- .gitignore | 2 ++ Makefile | 7 ++++-- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53a2ed9..57b6810 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,43 @@ 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 @@ -15,16 +52,12 @@ jobs: - 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) diff --git a/.gitignore b/.gitignore index b37181c..e36da5f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,8 @@ ENV/ .ruff_cache/ .mypy_cache/ .coverage +.coverage.* +coverage.xml htmlcov/ # Editors / OS diff --git a/Makefile b/Makefile index 6ec8d4a..630e9b1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install dev test lint clean playwright +.PHONY: install dev test lint typecheck clean playwright PYTHON ?= python3 @@ -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 {} + From 32c21be65fbd9290f6d372efbfa33e7571470897 Mon Sep 17 00:00:00 2001 From: DanieleGiovanardi2408 Date: Thu, 4 Jun 2026 10:00:02 +0200 Subject: [PATCH 4/4] docs: add changelog, contributing, IT readme, docs/, issue templates README gets badges, an MCP section, and a Quality bar; skill gains an MCP usage note. --- .github/ISSUE_TEMPLATE/bug_report.md | 44 +++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 35 +++++++ CHANGELOG.md | 61 ++++++++++++ CONTRIBUTING.md | 102 ++++++++++++++++++++ README.it.md | 89 ++++++++++++++++++ README.md | 108 ++++++++++++++++++++-- docs/AUTHENTICATION.md | 73 +++++++++++++++ docs/FAQ.md | 99 ++++++++++++++++++++ skills/linkedin-cli.md | 15 +++ 9 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 README.it.md create mode 100644 docs/AUTHENTICATION.md create mode 100644 docs/FAQ.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..3fb69c2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -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 + + + +``` +$ linkedin --verbose + +``` + +## What you expected + + + +## Reproduction + + + +1. +2. +3. + +## Environment + +- linkedin-cli version: +- Python: +- OS: +- Output mode: +- Surface: + +## Additional context + + + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b5784bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,35 @@ +--- +name: Feature request +about: Suggest a capability or change +title: "[feature] " +labels: enhancement +--- + +## What are you trying to do + + + +## Why the current behavior doesn't fit + + + +## Proposed shape (optional) + + + +```bash +# example invocation +``` + +## Surface + + + +## Terms-of-Service note + + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c5f92b6 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..08ebb25 --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.it.md b/README.it.md new file mode 100644 index 0000000..056686c --- /dev/null +++ b/README.it.md @@ -0,0 +1,89 @@ +# linkedin-cli + +Client da riga di comando per **LinkedIn**, pensato sia per agenti AI sia per +sviluppatori. Pilota l'API interna Voyager come fa il sito: nessuna API key, +solo i cookie di sessione catturati da un browser reale. + +> English: [README.md](README.md) — versione completa. + +> [!WARNING] +> LinkedIn vieta l'uso automatizzato nei propri Termini di Servizio +> (sezione 8.2). Questo strumento è per uso personale e di ricerca: comportati +> come un utente normale, rispetta i rate limit, non fare scraping di massa né +> spam. Usalo a tuo rischio, preferibilmente su un account non principale. + +## Requisiti + +- Python 3.11+ +- Un account LinkedIn +- Chromium (installato in automatico da `make install` via Playwright) + +## Installazione + +```bash +pip install mayai-linkedin-cli +playwright install chromium +``` + +Installa il comando `linkedin` (e `linkedin-mcp` per il server MCP). + +## Quick start + +```bash +# 1. Autenticazione — apre una finestra Chromium per fare login normalmente. +linkedin auth login + +# 2. Verifica +linkedin auth status + +# 3. Cerca una persona (NDJSON con --json) +linkedin --json search people "Mario Rossi" + +# 4. Leggi un profilo per public id o URL completo +linkedin --json profile get mario-rossi-9558832a + +# 5. Manda una richiesta di connessione (prima in dry-run, se vuoi) +linkedin connections send mario-rossi-9558832a --dry-run +linkedin connections send mario-rossi-9558832a + +# 6. Ultime conversazioni in arrivo +linkedin --json messages list +``` + +## Server MCP + +`linkedin-cli` include un server MCP nativo: gli agenti AI (come Claude +Desktop) usano LinkedIn direttamente come tool, senza sottoprocessi né +parsing. Aggiungi a `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "linkedin": { "command": "/percorso/di/linkedin-mcp" } + } +} +``` + +Trova il percorso con `which linkedin-mcp`. Dettagli e lista dei tool nel +[README.md](README.md#mcp-server). + +## Sicurezza + +Le azioni di scrittura sono protette: alla CLI `connections send` e +`messages send` accettano `--dry-run`; i tool MCP corrispondenti richiedono +`confirm=True` esplicito e sono rate-limited per sessione. La CLI rispetta +inoltre quote giornaliere per account e un ritardo casuale tra le richieste, +per non farsi notare dall'antiabuso di LinkedIn. + +I cookie stanno cifrati con Fernet in `~/.config/mayai-cli/linkedin/` +(`credentials.json` + `key.bin`, permessi `0600`). Mai password come argomento, +mai cookie in chiaro. Dettagli: [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md). + +## Altro + +Per il riferimento completo (tutti i comandi, flag, parser Voyager, MCP, +quote, contributi): vedi [README.md](README.md) e [docs/](docs/). + +## Licenza + +MIT — vedi [LICENSE](./LICENSE). diff --git a/README.md b/README.md index a50ecc4..2ee4009 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +[![PyPI version](https://img.shields.io/pypi/v/mayai-linkedin-cli.svg)](https://pypi.org/project/mayai-linkedin-cli/) +[![Python versions](https://img.shields.io/pypi/pyversions/mayai-linkedin-cli.svg)](https://pypi.org/project/mayai-linkedin-cli/) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Built for AI agents](https://img.shields.io/badge/Built%20for-AI%20agents-purple)](https://mayai.it) +[![Tests](https://github.com/mayai-it/linkedin-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mayai-it/linkedin-cli/actions/workflows/ci.yml) +[![mypy](https://img.shields.io/badge/mypy-checked-blue)](https://mypy-lang.org/) +[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-261230.svg)](https://github.com/astral-sh/ruff) +[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) + +> Italiano: [README.it.md](README.it.md) — versione ridotta. + > [!WARNING] > This tool uses LinkedIn's internal Voyager API, which is not publicly documented > and is not officially supported by LinkedIn. Usage may violate LinkedIn's @@ -7,13 +18,23 @@ # linkedin-cli -Command-line client for **LinkedIn**, driving the internal Voyager API the -same way the website does. Built for both humans and AI agents: -context-efficient defaults, NDJSON output for piping into LLMs or `jq`, and -no API key — just session cookies captured from a real browser. +Command-line client **and MCP server** for **LinkedIn**, driving the internal +Voyager API the same way the website does. Built for both humans and AI agents: +context-efficient defaults, NDJSON output for piping into LLMs or `jq`, a native +MCP server for tools like Claude Desktop, and no API key — just session cookies +captured from a real browser. Part of [MayAI CLI](https://mayai.it). +## Quality bar + +- **Cross-platform CI**: Ubuntu / macOS / Windows × Python 3.11 / 3.12 / 3.13, + plus a `pip-audit` job — via GitHub Actions. +- **`ruff` + blocking `mypy`** on every push; **72 tests** run fully offline + against mocks (no live LinkedIn traffic, no browser launch). +- **Two surfaces, one core**: the `linkedin` CLI and the `linkedin-mcp` server + share the same Voyager client, throttle, and daily-quota safety net. + ## Requirements - Python 3.11+ @@ -44,12 +65,62 @@ make install The `make install` target installs the package in editable mode and runs `playwright install chromium`. -For local development (adds `pytest`, `ruff`): +For local development (adds `pytest`, `ruff`, `mypy`): ```bash make dev ``` +## MCP Server + +linkedin-cli ships with a native MCP server, letting AI agents like Claude +access your LinkedIn directly — no subprocess, no JSON parsing. It runs over +stdio and reuses the same session cookies, throttle, and daily quotas as the +CLI; it refuses to start (exit code `2`) until you've run `linkedin auth login`. + +### Setup with Claude Desktop + +Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "linkedin": { + "command": "/path/to/linkedin-mcp" + } + } +} +``` + +Find your path with `which linkedin-mcp`. + +### Compatible MCP clients + +| Client | Status | +|--------|--------| +| Claude Desktop | Tested | +| Cursor | Compatible (same stdio config) | +| Continue (VS Code) | Compatible (same stdio config) | +| Zed | Compatible (same stdio config) | + +### Available tools + +| Tool | Description | +|------|-------------| +| `linkedin_profile_get` | Fetch a profile by public id or URL | +| `linkedin_search_people` | Search people by name / role (`company`, `title`, `limit`) | +| `linkedin_search_companies` | Search companies by name | +| `linkedin_connections_list` | List first-degree connections, newest first | +| `linkedin_connections_pending` | List incoming invitations | +| `linkedin_messages_list` | List latest conversations | +| `linkedin_connections_send` | Send a connection request (requires `confirm=True`) | +| `linkedin_messages_send` | Send a 1:1 message (requires `confirm=True`) | +| `linkedin_auth_status` | Check authentication status | + +The two write tools refuse to act without an explicit `confirm=True`, support +`dry_run=True`, and are rate-limited per session on top of the daily quotas — +see the [Safety note](#safety-note-on-write-actions) below. + ## Quick start ```bash @@ -116,6 +187,21 @@ These work in any position (before or after the subcommand): | `1` | Application error (network, rate limit, search 500, bad arguments) | | `2` | Not authenticated, or session expired — run `linkedin auth login` | +### Safety note on write actions + +Connection requests and messages are real, user-visible actions that count +against daily quotas and feed LinkedIn's anti-abuse heuristics. To avoid +accidents: + +- **CLI**: `connections send` and `messages send` accept `--dry-run` to print + the intended action without contacting LinkedIn. +- **MCP**: the `linkedin_connections_send` and `linkedin_messages_send` tools + refuse to act unless the caller passes `confirm=True`, support `dry_run=True`, + and are rate-limited to 5 actions per target per 5 minutes per session. +- **Both** honor the per-account daily quotas (`connections` 15/day, `messages` + 25/day) unless `--no-throttle` is set — which you should treat as the fastest + way to get an account flagged. + ## Authentication LinkedIn does **not** expose a public API for the operations this CLI @@ -409,10 +495,20 @@ get ` when you need them): make dev # install with dev extras + Chromium make playwright # install just the Chromium binary make test # run pytest -make lint # run ruff +make lint # run ruff (linkedin_cli/ + tests/) +make typecheck # run mypy linkedin_cli/ make clean # remove caches and build artifacts ``` +Contributing guide and PR checklist: [CONTRIBUTING.md](CONTRIBUTING.md). + +## Help + +- [docs/AUTHENTICATION.md](docs/AUTHENTICATION.md) — login flow, cookie storage, session lifetime +- [docs/FAQ.md](docs/FAQ.md) — common questions and gotchas (queryId 500s, rate limits, MCP setup) +- [CHANGELOG.md](CHANGELOG.md) — release notes +- [Issues](https://github.com/mayai-it/linkedin-cli/issues) — bug reports and feature requests + ## License MIT — see [LICENSE](./LICENSE). diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md new file mode 100644 index 0000000..83239e0 --- /dev/null +++ b/docs/AUTHENTICATION.md @@ -0,0 +1,73 @@ +# Authentication + +LinkedIn does **not** expose a public API for the operations this CLI performs +— no OAuth flow, no developer app, no API key. The website itself authenticates +with browser cookies, and that's what `linkedin-cli` captures. + +## The login flow + +`linkedin auth login`: + +1. Launches Chromium via Playwright and navigates to + `https://www.linkedin.com/feed/`. If you're already signed in (cookies in + the Playwright profile) it captures the session immediately. Otherwise you + sign in normally — including 2FA / captcha — and the CLI watches the cookie + jar. +2. Waits for `li_at` + `JSESSIONID` to appear **and** for the page URL to leave + the auth flow (`/login`, `/checkpoint`, `/uas`, `/authwall`, `/signup`). +3. Calls `/voyager/api/me` with the captured cookies + the full Voyager header + set to resolve your own `urn:li:fsd_profile:` (needed for the messaging + endpoint). Falls back to `/voyager/api/identity/profiles/me`, and finally to + decoding the `li_at` cookie, if both fail. +4. Generates a Fernet key (if not already present) and encrypts the cookie jar + with it. + +You **cannot** script this login: it needs a real browser and a real human to +complete any challenge. Never pass a LinkedIn username/password to the CLI — +there's no such flag, and password login from a script trips a checkpoint. + +## File layout + +Everything lives under `~/.config/mayai-cli/linkedin/`, each file mode `0600`: + +| File | Contents | +|---|---| +| `credentials.json` | The Fernet-encrypted cookie blob (`li_at`, `JSESSIONID`, the full jar, and your member URN). | +| `key.bin` | The 32-byte Fernet key used to decrypt `credentials.json`. | +| `quotas.json` | Per-account daily action counters (`connections`, `messages`, `api_total`); resets at local midnight. | + +`linkedin auth logout` deletes `credentials.json` and `key.bin`. + +> The encryption protects the cookies at rest from casual reading, but anyone +> with both files can reconstruct the session. Treat the whole directory as a +> secret: never commit it, never paste its contents, never let an agent `cat` +> it. + +## The CSRF quirk + +Every Voyager request needs a `csrf-token` header equal to the `JSESSIONID` +cookie value **with the surrounding double-quote characters stripped**. +Playwright captures the cookie verbatim, *including* the quotes; Voyager 403s if +you send the quoted form. `auth/credentials.py:normalize_csrf` is the single +source of truth that strips them — used on every request and during the `/me` +lookup at login. + +## Session lifetime + +LinkedIn rotates `li_at` aggressively (typically every couple of months, +sometimes sooner). When it expires you'll see: + +``` +error: session expired or invalid — run `linkedin auth login` again +``` + +(exit code `2`). Re-run login — the same Playwright profile is reused, so you +usually don't have to re-enter credentials. Refreshing cookies also lets the +next people-search call scrape a current `queryId` (see the FAQ). + +## Headless mode + +`linkedin auth login --headless` only works if the cookies are already present +from a previous interactive run — there's no way to satisfy a fresh login +challenge without a visible browser. Use it to refresh a still-valid profile, +not for first-time setup. diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..a3c089c --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,99 @@ +# FAQ + +## Is this allowed? Will my account get banned? + +LinkedIn's Terms of Service (section 8.2) forbid automated access. This tool is +for personal and research use; using it carries a real risk that LinkedIn +restricts or bans your account. `linkedin-cli` is built to behave like a careful +human — jittered delays between requests, conservative daily quotas — but it +cannot make automation *allowed*. Use it at your own risk, ideally on a +non-primary account, and never for bulk scraping or spam. + +## Where are my credentials stored? + +Fernet-encrypted under `~/.config/mayai-cli/linkedin/` — +`credentials.json` (the cookie blob) and `key.bin` (the key), both mode `0600`. +`linkedin auth logout` removes both. Full layout: +[AUTHENTICATION.md](AUTHENTICATION.md). + +## People search suddenly returns `HTTP 500` / `all N queryId candidates returned 500` + +LinkedIn rotated the `queryId` hash for the people-search GraphQL endpoint when +it shipped a new web bundle, and the cached/fallback ids are now stale. The CLI +scrapes a fresh id from LinkedIn's own rendered search page on the next session, +so the fix is almost always: + +```bash +linkedin auth login # refresh cookies → next call scrapes a live queryId +``` + +If it still 500s, LinkedIn may have changed the response shape — run with +`--verbose` and open an issue with the (redacted) body preview. + +## I get `error: rate limited by LinkedIn` (HTTP 429) + +Stop. Wait several minutes — ideally longer — before trying again. Do **not** +retry in a loop: LinkedIn extends the penalty the more you hit it while limited. +This is separate from the CLI's own daily quotas (below), which are a local +safety net, not LinkedIn's limit. + +## What are the daily quotas and how do I change them? + +State lives in `~/.config/mayai-cli/linkedin/quotas.json` and resets at local +midnight. Defaults: + +| Quota | Limit | Counted when | +|---|---|---| +| `connections` | 15/day | A successful `connections send` POST. | +| `messages` | 25/day | Each `messages send`. | +| `api_total` | 200/day | Every Voyager request. | + +`--no-throttle` disables **both** the inter-request delay and these quota checks. +It's the single fastest way to get an account flagged — only use it if you fully +accept the risk. + +## I added `linkedin-mcp` to my MCP client and the tools don't show up + +Three usual causes: + +1. **Path**: the `command` in the MCP client config must be the absolute path to + the installed entry point. Run `which linkedin-mcp` and paste the result + verbatim — `~` and shell aliases are not expanded. +2. **Not authenticated**: the server refuses to start (exit code `2`) if no + cookies are stored. Run `linkedin auth login` first, then restart the MCP + client. +3. **Server logs**: `linkedin-mcp` writes diagnostics to stderr. In Claude + Desktop, check `~/Library/Logs/Claude/mcp-server-linkedin.log`. The first + line tells you whether it bound to a session or which precondition failed. + +## Can the agent send connection requests or messages on its own? + +No. The MCP write tools (`linkedin_connections_send`, `linkedin_messages_send`) +refuse to act unless the caller passes `confirm=True`, and they support +`dry_run=True` for validation. They're also rate-limited per session (5 actions +per target per 5 minutes) on top of the daily quotas. The intent is that a human +approves each real action. + +## `messages send` says my recipient "looks like a public id" + +LinkedIn's messaging endpoint wants a numeric `member` id or a +`urn:li:member:N` URN — not a vanity public id. Resolve it first: + +```bash +linkedin --json profile get mario-rossi-9558832a # read the member_id field +linkedin messages send "Ciao Mario, ..." +``` + +## Why are `connections list` rows so bare? + +LinkedIn's connections endpoint does not inline profile names — each row is just +`connection_urn` + `connected_at`. Resolving names would mean one extra API call +per row, which we won't do implicitly (it burns the `api_total` quota fast). When +you need the full profile, run `linkedin profile get `. + +## SSL / certificate errors + +The client uses the OS root certificate store. On macOS with a python.org +build, run the bundled `Install Certificates.command`. On Linux, ensure the +system CA bundle is present (`pip install --upgrade certifi` if needed). We do +not disable certificate verification. diff --git a/skills/linkedin-cli.md b/skills/linkedin-cli.md index 27d30c5..6550d95 100644 --- a/skills/linkedin-cli.md +++ b/skills/linkedin-cli.md @@ -232,3 +232,18 @@ linkedin --json connections list --limit 20 to first/last 4 chars; don't try to recover them. - Full message bodies of the user's PMs without explicit permission — treat private DMs like private email. + +## MCP server (alternative to the CLI) + +If the host exposes linkedin-cli as an MCP server (`linkedin-mcp`), prefer the +native tools over shelling out — same data, no subprocess or NDJSON parsing. +Tool names mirror the CLI: `linkedin_profile_get`, `linkedin_search_people`, +`linkedin_search_companies`, `linkedin_connections_list`, +`linkedin_connections_pending`, `linkedin_messages_list`, +`linkedin_auth_status`, plus the gated write tools `linkedin_connections_send` +and `linkedin_messages_send`. + +The two write tools will NOT act unless you pass `confirm=True`, and they accept +`dry_run=True` to preview. Get the user's explicit go-ahead before setting +`confirm=True` — do not connect or message anyone autonomously. The server +refuses to start until `linkedin auth login` has been run.