linkedin-mcp-zero 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- linkedin_mcp_zero/__init__.py +3 -0
- linkedin_mcp_zero/__main__.py +4 -0
- linkedin_mcp_zero/cache/__init__.py +3 -0
- linkedin_mcp_zero/cache/ttl_cache.py +41 -0
- linkedin_mcp_zero/config/__init__.py +3 -0
- linkedin_mcp_zero/config/autodetect.py +152 -0
- linkedin_mcp_zero/config/defaults.py +7 -0
- linkedin_mcp_zero/config/install.py +412 -0
- linkedin_mcp_zero/config/settings.py +43 -0
- linkedin_mcp_zero/engines/__init__.py +3 -0
- linkedin_mcp_zero/engines/browser.py +503 -0
- linkedin_mcp_zero/engines/matching.py +175 -0
- linkedin_mcp_zero/engines/multi_board.py +60 -0
- linkedin_mcp_zero/engines/public_api.py +208 -0
- linkedin_mcp_zero/engines/resume.py +171 -0
- linkedin_mcp_zero/engines/voyager.py +34 -0
- linkedin_mcp_zero/main.py +223 -0
- linkedin_mcp_zero/metrics/__init__.py +1 -0
- linkedin_mcp_zero/metrics/store.py +159 -0
- linkedin_mcp_zero/metrics/tracking.py +189 -0
- linkedin_mcp_zero/py.typed +1 -0
- linkedin_mcp_zero/scraping/__init__.py +1 -0
- linkedin_mcp_zero/scraping/guest_api.py +329 -0
- linkedin_mcp_zero/scraping/schema.py +32 -0
- linkedin_mcp_zero/server/__init__.py +1 -0
- linkedin_mcp_zero/server/app.py +727 -0
- linkedin_mcp_zero/server/catalog.py +341 -0
- linkedin_mcp_zero/server/middleware.py +95 -0
- linkedin_mcp_zero/storage/__init__.py +3 -0
- linkedin_mcp_zero/storage/db.py +111 -0
- linkedin_mcp_zero/storage/vector_db.py +83 -0
- linkedin_mcp_zero/utils/__init__.py +1 -0
- linkedin_mcp_zero/utils/circuit_breaker.py +58 -0
- linkedin_mcp_zero/utils/compress.py +73 -0
- linkedin_mcp_zero/utils/errors.py +17 -0
- linkedin_mcp_zero/utils/llm.py +67 -0
- linkedin_mcp_zero/utils/logging.py +19 -0
- linkedin_mcp_zero/utils/oauth.py +76 -0
- linkedin_mcp_zero/utils/rate_limit.py +25 -0
- linkedin_mcp_zero/utils/telemetry.py +82 -0
- linkedin_mcp_zero-0.3.0.data/data/linkedin_mcp_zero/py.typed +1 -0
- linkedin_mcp_zero-0.3.0.dist-info/METADATA +332 -0
- linkedin_mcp_zero-0.3.0.dist-info/RECORD +46 -0
- linkedin_mcp_zero-0.3.0.dist-info/WHEEL +4 -0
- linkedin_mcp_zero-0.3.0.dist-info/entry_points.txt +3 -0
- linkedin_mcp_zero-0.3.0.dist-info/licenses/LICENSE +17 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections import OrderedDict
|
|
5
|
+
from collections.abc import Hashable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Generic, TypeVar
|
|
8
|
+
|
|
9
|
+
T = TypeVar("T")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CacheEntry(Generic[T]):
|
|
14
|
+
value: T
|
|
15
|
+
expires_at: float
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TTLCache(Generic[T]):
|
|
19
|
+
def __init__(self, ttl_seconds: int, max_entries: int = 200) -> None:
|
|
20
|
+
self.ttl_seconds = ttl_seconds
|
|
21
|
+
self.max_entries = max_entries
|
|
22
|
+
self._items: OrderedDict[Hashable, CacheEntry[T]] = OrderedDict()
|
|
23
|
+
|
|
24
|
+
def get(self, key: Hashable) -> T | None:
|
|
25
|
+
entry = self._items.get(key)
|
|
26
|
+
if entry is None:
|
|
27
|
+
return None
|
|
28
|
+
if entry.expires_at < time.monotonic():
|
|
29
|
+
self._items.pop(key, None)
|
|
30
|
+
return None
|
|
31
|
+
self._items.move_to_end(key)
|
|
32
|
+
return entry.value
|
|
33
|
+
|
|
34
|
+
def set(self, key: Hashable, value: T) -> None:
|
|
35
|
+
self._items[key] = CacheEntry(value=value, expires_at=time.monotonic() + self.ttl_seconds)
|
|
36
|
+
self._items.move_to_end(key)
|
|
37
|
+
while len(self._items) > self.max_entries:
|
|
38
|
+
self._items.popitem(last=False)
|
|
39
|
+
|
|
40
|
+
def stats(self) -> dict[str, int]:
|
|
41
|
+
return {"entries": len(self._items), "max": self.max_entries, "ttl": self.ttl_seconds}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import asdict, dataclass
|
|
8
|
+
from importlib.util import find_spec
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from platformdirs import user_data_dir
|
|
12
|
+
|
|
13
|
+
from linkedin_mcp_zero.config.defaults import DATA_DIR_NAME
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class RuntimeStatus:
|
|
18
|
+
os: str
|
|
19
|
+
python: str
|
|
20
|
+
python_ok: bool
|
|
21
|
+
executable: str
|
|
22
|
+
cpu_count: int | None
|
|
23
|
+
ram_total_mb: int | None
|
|
24
|
+
ram_available_mb: int | None
|
|
25
|
+
disk_free_mb: int | None
|
|
26
|
+
data_dir: str
|
|
27
|
+
chrome: str | None
|
|
28
|
+
cdp_url: str
|
|
29
|
+
cdp_env_hint: bool
|
|
30
|
+
docker: bool
|
|
31
|
+
display: bool
|
|
32
|
+
optional: dict[str, bool]
|
|
33
|
+
mode: str
|
|
34
|
+
notes: list[str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_chrome() -> str | None:
|
|
38
|
+
candidates = [
|
|
39
|
+
"google-chrome",
|
|
40
|
+
"google-chrome-stable",
|
|
41
|
+
"chromium",
|
|
42
|
+
"chromium-browser",
|
|
43
|
+
"brave-browser",
|
|
44
|
+
"microsoft-edge",
|
|
45
|
+
]
|
|
46
|
+
for name in candidates:
|
|
47
|
+
path = shutil.which(name)
|
|
48
|
+
if path:
|
|
49
|
+
return path
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def detect_runtime(data_dir: str | None = None) -> dict[str, object]:
|
|
54
|
+
ram_total, ram_available = _memory_mb()
|
|
55
|
+
disk_free = _disk_free_mb(data_dir)
|
|
56
|
+
optional = {
|
|
57
|
+
"jobspy": _has_module("jobspy"),
|
|
58
|
+
"pymupdf": _has_module("fitz"),
|
|
59
|
+
"playwright": _has_module("playwright.async_api"),
|
|
60
|
+
"patchright": _has_module("patchright.async_api"),
|
|
61
|
+
}
|
|
62
|
+
mode, notes = _recommend_mode(ram_available, disk_free, optional)
|
|
63
|
+
status = RuntimeStatus(
|
|
64
|
+
os=platform.system(),
|
|
65
|
+
python=platform.python_version(),
|
|
66
|
+
python_ok=sys.version_info >= (3, 10),
|
|
67
|
+
executable=sys.executable,
|
|
68
|
+
cpu_count=os.cpu_count(),
|
|
69
|
+
ram_total_mb=ram_total,
|
|
70
|
+
ram_available_mb=ram_available,
|
|
71
|
+
disk_free_mb=disk_free,
|
|
72
|
+
data_dir=str(_data_dir(data_dir)),
|
|
73
|
+
chrome=detect_chrome(),
|
|
74
|
+
cdp_url=os.environ.get("LINKEDIN_MCP_CDP_URL", "http://127.0.0.1:9222"),
|
|
75
|
+
cdp_env_hint="LINKEDIN_MCP_CDP_URL" in os.environ,
|
|
76
|
+
docker=os.path.exists("/.dockerenv"),
|
|
77
|
+
display=bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")),
|
|
78
|
+
optional=optional,
|
|
79
|
+
mode=mode,
|
|
80
|
+
notes=notes,
|
|
81
|
+
)
|
|
82
|
+
return asdict(status)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _data_dir(data_dir: str | None = None) -> Path:
|
|
86
|
+
return Path(data_dir or user_data_dir(DATA_DIR_NAME)).expanduser()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _has_module(name: str) -> bool:
|
|
90
|
+
try:
|
|
91
|
+
return find_spec(name) is not None
|
|
92
|
+
except ModuleNotFoundError:
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _disk_free_mb(data_dir: str | None = None) -> int | None:
|
|
97
|
+
try:
|
|
98
|
+
path = _data_dir(data_dir)
|
|
99
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
100
|
+
usage = shutil.disk_usage(path)
|
|
101
|
+
return round(usage.free / 1024 / 1024)
|
|
102
|
+
except OSError:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _memory_mb() -> tuple[int | None, int | None]:
|
|
107
|
+
if platform.system() == "Linux":
|
|
108
|
+
try:
|
|
109
|
+
values: dict[str, int] = {}
|
|
110
|
+
for line in Path("/proc/meminfo").read_text(encoding="utf-8").splitlines():
|
|
111
|
+
key, raw = line.split(":", 1)
|
|
112
|
+
values[key] = int(raw.strip().split()[0])
|
|
113
|
+
total = round(values["MemTotal"] / 1024)
|
|
114
|
+
available = round(values.get("MemAvailable", values["MemFree"]) / 1024)
|
|
115
|
+
return total, available
|
|
116
|
+
except (OSError, KeyError, ValueError):
|
|
117
|
+
return None, None
|
|
118
|
+
if hasattr(os, "sysconf"):
|
|
119
|
+
try:
|
|
120
|
+
pages = os.sysconf("SC_PHYS_PAGES")
|
|
121
|
+
page_size = os.sysconf("SC_PAGE_SIZE")
|
|
122
|
+
return round(pages * page_size / 1024 / 1024), None
|
|
123
|
+
except (OSError, ValueError):
|
|
124
|
+
return None, None
|
|
125
|
+
return None, None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _recommend_mode(
|
|
129
|
+
ram_available_mb: int | None,
|
|
130
|
+
disk_free_mb: int | None,
|
|
131
|
+
optional: dict[str, bool],
|
|
132
|
+
) -> tuple[str, list[str]]:
|
|
133
|
+
notes: list[str] = []
|
|
134
|
+
mode = "full"
|
|
135
|
+
if ram_available_mb is not None and ram_available_mb < 700:
|
|
136
|
+
mode = "lean"
|
|
137
|
+
notes.append("Low available RAM: keep Engine 2 browser closed until needed.")
|
|
138
|
+
elif ram_available_mb is not None and ram_available_mb < 1500:
|
|
139
|
+
mode = "balanced"
|
|
140
|
+
notes.append("Moderate available RAM: Engine 1/local tools are preferred by default.")
|
|
141
|
+
if disk_free_mb is not None and disk_free_mb < 512:
|
|
142
|
+
mode = "lean"
|
|
143
|
+
notes.append("Low disk space: avoid large exports and browser profiles.")
|
|
144
|
+
if not optional["playwright"]:
|
|
145
|
+
notes.append("Browser tools need `uv sync --extra browser`.")
|
|
146
|
+
if not optional["jobspy"]:
|
|
147
|
+
notes.append("Multi-board search needs `uv sync --extra multi`.")
|
|
148
|
+
if not optional["pymupdf"]:
|
|
149
|
+
notes.append("PDF resume parsing needs `uv sync --extra pdf`.")
|
|
150
|
+
if not notes:
|
|
151
|
+
notes.append("System looks ready for local and public no-login workflow.")
|
|
152
|
+
return mode, notes
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
DEFAULT_LIMIT = 10
|
|
2
|
+
MAX_LIMIT = 50
|
|
3
|
+
CACHE_TTL_SECONDS = 300
|
|
4
|
+
CACHE_MAX_ENTRIES = 200
|
|
5
|
+
GUEST_API_BASE = "https://www.linkedin.com/jobs-guest/jobs/api"
|
|
6
|
+
DATA_DIR_NAME = "linkedin-mcp-zero"
|
|
7
|
+
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
|
|
13
|
+
ClientName = Literal["claude-desktop", "claude-code", "cursor", "vscode"]
|
|
14
|
+
PackageSource = Literal["pypi", "github"]
|
|
15
|
+
PackageExtra = Literal["browser", "multi", "pdf"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class InstallResult:
|
|
20
|
+
client: str
|
|
21
|
+
path: str | None
|
|
22
|
+
changed: bool
|
|
23
|
+
backup: str | None
|
|
24
|
+
server: str
|
|
25
|
+
command: list[str] | None = None
|
|
26
|
+
message: str = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class VerifyResult:
|
|
31
|
+
client: str
|
|
32
|
+
ok: bool
|
|
33
|
+
path: str | None
|
|
34
|
+
checks: list[dict[str, object]]
|
|
35
|
+
repair_hint: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
SERVER_NAME = "linkedin-zero"
|
|
39
|
+
GITHUB_URL = "git+https://github.com/SanthaKumar-K-2004/linkedin-mcp-zero"
|
|
40
|
+
FILE_CLIENTS: dict[str, dict[str, str]] = {
|
|
41
|
+
"claude-desktop": {"key": "mcpServers"},
|
|
42
|
+
"cursor": {"key": "mcpServers"},
|
|
43
|
+
"vscode": {"key": "servers"},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def config_path(client: ClientName, cwd: Path | None = None) -> Path:
|
|
48
|
+
system = platform.system()
|
|
49
|
+
home = Path.home()
|
|
50
|
+
if client == "cursor":
|
|
51
|
+
base = cwd or Path.cwd()
|
|
52
|
+
return base / ".cursor" / "mcp.json"
|
|
53
|
+
if client == "vscode":
|
|
54
|
+
if _is_wsl():
|
|
55
|
+
return home / ".vscode-server" / "data" / "User" / "mcp.json"
|
|
56
|
+
if system == "Darwin":
|
|
57
|
+
return home / "Library" / "Application Support" / "Code" / "User" / "mcp.json"
|
|
58
|
+
if system == "Windows":
|
|
59
|
+
return home / "AppData" / "Roaming" / "Code" / "User" / "mcp.json"
|
|
60
|
+
return home / ".config" / "Code" / "User" / "mcp.json"
|
|
61
|
+
if client == "claude-code":
|
|
62
|
+
return home / ".claude.json"
|
|
63
|
+
if system == "Darwin":
|
|
64
|
+
return home / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
65
|
+
if system == "Windows":
|
|
66
|
+
return home / "AppData" / "Roaming" / "Claude" / "claude_desktop_config.json"
|
|
67
|
+
return home / ".config" / "Claude" / "claude_desktop_config.json"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def install_client_config(
|
|
71
|
+
client: ClientName,
|
|
72
|
+
*,
|
|
73
|
+
path: str | None = None,
|
|
74
|
+
cwd: Path | None = None,
|
|
75
|
+
source: PackageSource = "pypi",
|
|
76
|
+
extras: list[PackageExtra] | None = None,
|
|
77
|
+
enable_browser: bool = False,
|
|
78
|
+
) -> InstallResult:
|
|
79
|
+
enable_browser = enable_browser or "browser" in (extras or [])
|
|
80
|
+
if client == "claude-code":
|
|
81
|
+
return _install_claude_code(source, extras=extras, enable_browser=enable_browser)
|
|
82
|
+
|
|
83
|
+
target = Path(path).expanduser() if path else config_path(client, cwd)
|
|
84
|
+
config_key = _config_key(client)
|
|
85
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
data = _load_json(target)
|
|
87
|
+
servers = data.setdefault(config_key, {})
|
|
88
|
+
if not isinstance(servers, dict):
|
|
89
|
+
raise ValueError(f"Existing config has non-object `{config_key}`; refusing to overwrite.")
|
|
90
|
+
|
|
91
|
+
server_config = server_config_for(source, extras=extras, enable_browser=enable_browser)
|
|
92
|
+
old = servers.get(SERVER_NAME)
|
|
93
|
+
if old == server_config:
|
|
94
|
+
return InstallResult(
|
|
95
|
+
client,
|
|
96
|
+
str(target),
|
|
97
|
+
False,
|
|
98
|
+
None,
|
|
99
|
+
SERVER_NAME,
|
|
100
|
+
None,
|
|
101
|
+
"Already current.",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
backup = _backup(target) if target.exists() else None
|
|
105
|
+
servers[SERVER_NAME] = server_config
|
|
106
|
+
_atomic_write_json(target, data)
|
|
107
|
+
return InstallResult(client, str(target), True, backup, SERVER_NAME, None, "Config updated.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def verify_client_config(
|
|
111
|
+
client: ClientName,
|
|
112
|
+
*,
|
|
113
|
+
path: str | None = None,
|
|
114
|
+
cwd: Path | None = None,
|
|
115
|
+
) -> VerifyResult:
|
|
116
|
+
if client == "claude-code":
|
|
117
|
+
claude = shutil.which("claude")
|
|
118
|
+
list_ok = False
|
|
119
|
+
list_detail = "not checked"
|
|
120
|
+
if claude:
|
|
121
|
+
completed = subprocess.run(
|
|
122
|
+
[claude, "mcp", "list"],
|
|
123
|
+
check=False,
|
|
124
|
+
capture_output=True,
|
|
125
|
+
text=True,
|
|
126
|
+
)
|
|
127
|
+
output = (completed.stdout or completed.stderr or "").strip()
|
|
128
|
+
list_ok = completed.returncode == 0 and SERVER_NAME in output
|
|
129
|
+
list_detail = output or f"exit {completed.returncode}"
|
|
130
|
+
claude_checks = [
|
|
131
|
+
{"name": "claude_cli_found", "ok": bool(claude), "detail": claude or "not found"},
|
|
132
|
+
{
|
|
133
|
+
"name": "claude_mcp_list_contains_linkedin_zero",
|
|
134
|
+
"ok": list_ok,
|
|
135
|
+
"detail": list_detail,
|
|
136
|
+
},
|
|
137
|
+
]
|
|
138
|
+
return VerifyResult(
|
|
139
|
+
client=client,
|
|
140
|
+
ok=all(bool(check["ok"]) for check in claude_checks),
|
|
141
|
+
path=None,
|
|
142
|
+
checks=claude_checks,
|
|
143
|
+
repair_hint=("Install Claude Code CLI, then run `linkedin-mcp-zero --install-client claude-code`."),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
target = Path(path).expanduser() if path else config_path(client, cwd)
|
|
147
|
+
config_key = _config_key(client)
|
|
148
|
+
checks: list[dict[str, object]] = []
|
|
149
|
+
data: dict[str, Any] = {}
|
|
150
|
+
try:
|
|
151
|
+
data = _load_json(target)
|
|
152
|
+
checks.append({"name": "valid_json", "ok": True, "detail": str(target)})
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
checks.append({"name": "valid_json", "ok": False, "detail": str(exc)})
|
|
155
|
+
|
|
156
|
+
servers = data.get(config_key) if data else None
|
|
157
|
+
checks.append(
|
|
158
|
+
{
|
|
159
|
+
"name": f"{config_key}.{SERVER_NAME}",
|
|
160
|
+
"ok": isinstance(servers, dict) and SERVER_NAME in servers,
|
|
161
|
+
"detail": "server entry present" if isinstance(servers, dict) else "server map missing",
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
entry = servers.get(SERVER_NAME) if isinstance(servers, dict) else {}
|
|
165
|
+
command = str(entry.get("command", "")) if isinstance(entry, dict) else ""
|
|
166
|
+
command_ok = bool(command and (Path(command).exists() or shutil.which(command)))
|
|
167
|
+
checks.append({"name": "command_resolves", "ok": command_ok, "detail": command or "missing"})
|
|
168
|
+
args = entry.get("args", []) if isinstance(entry, dict) else []
|
|
169
|
+
args_ok = isinstance(args, list) and bool(args)
|
|
170
|
+
checks.append({"name": "args_present", "ok": args_ok, "detail": args if args_ok else "missing"})
|
|
171
|
+
if command_ok and args_ok:
|
|
172
|
+
checks.append(_check_server_doctor(command, args))
|
|
173
|
+
if client == "vscode":
|
|
174
|
+
checks.append(
|
|
175
|
+
{
|
|
176
|
+
"name": "vscode_uses_servers_key",
|
|
177
|
+
"ok": "servers" in data and "mcpServers" not in data,
|
|
178
|
+
"detail": "VS Code requires top-level `servers`.",
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
ok = all(bool(item["ok"]) for item in checks)
|
|
182
|
+
return VerifyResult(
|
|
183
|
+
client=client,
|
|
184
|
+
ok=ok,
|
|
185
|
+
path=str(target),
|
|
186
|
+
checks=checks,
|
|
187
|
+
repair_hint=f"Run `linkedin-mcp-zero --install-client {client}` to repair this config.",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _check_server_doctor(command: str, args: object) -> dict[str, object]:
|
|
192
|
+
if not isinstance(args, list) or not args:
|
|
193
|
+
return {"name": "server_doctor_runs", "ok": False, "detail": "missing args"}
|
|
194
|
+
doctor_args = [str(part) for part in args] + ["--doctor", "--json"]
|
|
195
|
+
try:
|
|
196
|
+
completed = subprocess.run(
|
|
197
|
+
[command, *doctor_args],
|
|
198
|
+
check=False,
|
|
199
|
+
capture_output=True,
|
|
200
|
+
text=True,
|
|
201
|
+
timeout=45,
|
|
202
|
+
)
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
return {"name": "server_doctor_runs", "ok": False, "detail": str(exc)}
|
|
205
|
+
detail = (completed.stderr or completed.stdout or "").strip()
|
|
206
|
+
return {
|
|
207
|
+
"name": "server_doctor_runs",
|
|
208
|
+
"ok": completed.returncode == 0,
|
|
209
|
+
"detail": detail[:500] if detail else f"exit {completed.returncode}",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def preview_config(
|
|
214
|
+
source: PackageSource = "pypi",
|
|
215
|
+
*,
|
|
216
|
+
client: ClientName = "claude-desktop",
|
|
217
|
+
extras: list[PackageExtra] | None = None,
|
|
218
|
+
enable_browser: bool = False,
|
|
219
|
+
) -> dict[str, Any]:
|
|
220
|
+
enable_browser = enable_browser or "browser" in (extras or [])
|
|
221
|
+
if client == "claude-code":
|
|
222
|
+
return {
|
|
223
|
+
"command": claude_code_command(
|
|
224
|
+
source,
|
|
225
|
+
extras=extras,
|
|
226
|
+
enable_browser=enable_browser,
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
_config_key(client): {
|
|
231
|
+
SERVER_NAME: server_config_for(
|
|
232
|
+
source,
|
|
233
|
+
extras=extras,
|
|
234
|
+
enable_browser=enable_browser,
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def server_config_for(
|
|
241
|
+
source: PackageSource = "pypi",
|
|
242
|
+
*,
|
|
243
|
+
extras: list[PackageExtra] | None = None,
|
|
244
|
+
enable_browser: bool = False,
|
|
245
|
+
) -> dict[str, Any]:
|
|
246
|
+
command = shutil.which("uvx") or "uvx"
|
|
247
|
+
env = {
|
|
248
|
+
"PATH": _merged_path(command),
|
|
249
|
+
"HOME": str(Path.home()),
|
|
250
|
+
}
|
|
251
|
+
args = _uvx_args(source, extras=extras)
|
|
252
|
+
if enable_browser:
|
|
253
|
+
args.append("--enable-browser")
|
|
254
|
+
env["LINKEDIN_MCP_ENABLE_BROWSER"] = "true"
|
|
255
|
+
return {"command": command, "args": args, "env": env}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def claude_code_command(
|
|
259
|
+
source: PackageSource = "pypi",
|
|
260
|
+
*,
|
|
261
|
+
extras: list[PackageExtra] | None = None,
|
|
262
|
+
enable_browser: bool = False,
|
|
263
|
+
) -> list[str]:
|
|
264
|
+
config = server_config_for(source, extras=extras, enable_browser=enable_browser)
|
|
265
|
+
return [
|
|
266
|
+
"claude",
|
|
267
|
+
"mcp",
|
|
268
|
+
"add-json",
|
|
269
|
+
SERVER_NAME,
|
|
270
|
+
json.dumps(config, ensure_ascii=True),
|
|
271
|
+
"-s",
|
|
272
|
+
"user",
|
|
273
|
+
]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _install_claude_code(
|
|
277
|
+
source: PackageSource,
|
|
278
|
+
*,
|
|
279
|
+
extras: list[PackageExtra] | None,
|
|
280
|
+
enable_browser: bool,
|
|
281
|
+
) -> InstallResult:
|
|
282
|
+
command = claude_code_command(source, extras=extras, enable_browser=enable_browser)
|
|
283
|
+
if not shutil.which("claude"):
|
|
284
|
+
return InstallResult(
|
|
285
|
+
"claude-code",
|
|
286
|
+
None,
|
|
287
|
+
False,
|
|
288
|
+
None,
|
|
289
|
+
SERVER_NAME,
|
|
290
|
+
command,
|
|
291
|
+
"Claude Code CLI not found. Run the returned command after installing Claude Code.",
|
|
292
|
+
)
|
|
293
|
+
completed = subprocess.run(command, check=False, capture_output=True, text=True)
|
|
294
|
+
if completed.returncode != 0:
|
|
295
|
+
return InstallResult(
|
|
296
|
+
"claude-code",
|
|
297
|
+
None,
|
|
298
|
+
False,
|
|
299
|
+
None,
|
|
300
|
+
SERVER_NAME,
|
|
301
|
+
command,
|
|
302
|
+
(completed.stderr or completed.stdout or "Claude Code install failed.").strip(),
|
|
303
|
+
)
|
|
304
|
+
return InstallResult(
|
|
305
|
+
"claude-code",
|
|
306
|
+
None,
|
|
307
|
+
True,
|
|
308
|
+
None,
|
|
309
|
+
SERVER_NAME,
|
|
310
|
+
command,
|
|
311
|
+
"Claude Code MCP added.",
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _uvx_args(
|
|
316
|
+
source: PackageSource = "pypi",
|
|
317
|
+
*,
|
|
318
|
+
extras: list[PackageExtra] | None = None,
|
|
319
|
+
) -> list[str]:
|
|
320
|
+
if source == "github":
|
|
321
|
+
return ["--from", _github_requirement(extras), "mcp-server-linkedin-zero"]
|
|
322
|
+
if extras:
|
|
323
|
+
return ["--from", _package_requirement(extras), "mcp-server-linkedin-zero"]
|
|
324
|
+
return ["mcp-server-linkedin-zero"]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _config_key(client: ClientName) -> str:
|
|
328
|
+
if client not in FILE_CLIENTS:
|
|
329
|
+
raise ValueError(f"{client} does not use a direct JSON config file.")
|
|
330
|
+
return FILE_CLIENTS[client]["key"]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _package_requirement(extras: list[PackageExtra] | None = None) -> str:
|
|
334
|
+
clean = _clean_extras(extras)
|
|
335
|
+
if not clean:
|
|
336
|
+
return "mcp-server-linkedin-zero"
|
|
337
|
+
return f"mcp-server-linkedin-zero[{','.join(clean)}]"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _github_requirement(extras: list[PackageExtra] | None = None) -> str:
|
|
341
|
+
clean = _clean_extras(extras)
|
|
342
|
+
if not clean:
|
|
343
|
+
return GITHUB_URL
|
|
344
|
+
return f"mcp-server-linkedin-zero[{','.join(clean)}] @ {GITHUB_URL}"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _clean_extras(extras: list[PackageExtra] | None = None) -> list[str]:
|
|
348
|
+
allowed = {"browser", "multi", "pdf"}
|
|
349
|
+
return sorted({extra for extra in extras or [] if extra in allowed})
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _merged_path(command: str) -> str:
|
|
353
|
+
paths = [str(Path(command).parent)] if "/" in command else []
|
|
354
|
+
paths.extend([str(Path.home() / ".local" / "bin"), "/usr/local/bin", "/usr/bin", "/bin"])
|
|
355
|
+
existing = os.environ.get("PATH", "")
|
|
356
|
+
paths.extend(part for part in existing.split(":") if part)
|
|
357
|
+
return ":".join(dict.fromkeys(paths))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _load_json(path: Path) -> dict[str, Any]:
|
|
361
|
+
if not path.exists():
|
|
362
|
+
return {}
|
|
363
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
364
|
+
if not text:
|
|
365
|
+
return {}
|
|
366
|
+
try:
|
|
367
|
+
data = json.loads(text)
|
|
368
|
+
except json.JSONDecodeError as exc:
|
|
369
|
+
corrupt = path.with_suffix(f"{path.suffix}.invalid.{datetime.now():%Y%m%d%H%M%S}.bak")
|
|
370
|
+
shutil.copy2(path, corrupt)
|
|
371
|
+
recovered = _load_latest_valid_backup(path)
|
|
372
|
+
if recovered is None:
|
|
373
|
+
raise ValueError(
|
|
374
|
+
f"Config JSON is invalid at {path}: {exc}. A copy was saved to {corrupt}. Fix the JSON and rerun."
|
|
375
|
+
) from exc
|
|
376
|
+
return recovered
|
|
377
|
+
if not isinstance(data, dict):
|
|
378
|
+
raise ValueError("Existing config root must be a JSON object.")
|
|
379
|
+
return data
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _load_latest_valid_backup(path: Path) -> dict[str, Any] | None:
|
|
383
|
+
backups = sorted(path.parent.glob(f"{path.name}*.bak"), key=lambda item: item.stat().st_mtime)
|
|
384
|
+
for backup in reversed(backups):
|
|
385
|
+
try:
|
|
386
|
+
data = json.loads(backup.read_text(encoding="utf-8"))
|
|
387
|
+
except (OSError, json.JSONDecodeError):
|
|
388
|
+
continue
|
|
389
|
+
if isinstance(data, dict):
|
|
390
|
+
return data
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _backup(path: Path) -> str:
|
|
395
|
+
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
|
396
|
+
backup = path.with_suffix(f"{path.suffix}.{stamp}.bak")
|
|
397
|
+
shutil.copy2(path, backup)
|
|
398
|
+
return str(backup)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _atomic_write_json(path: Path, data: dict[str, Any]) -> None:
|
|
402
|
+
tmp = path.with_suffix(f"{path.suffix}.tmp")
|
|
403
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
|
404
|
+
tmp.replace(path)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _is_wsl() -> bool:
|
|
408
|
+
try:
|
|
409
|
+
release = Path("/proc/sys/kernel/osrelease").read_text(encoding="utf-8").lower()
|
|
410
|
+
except OSError:
|
|
411
|
+
return False
|
|
412
|
+
return "microsoft" in release or "wsl" in release
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
5
|
+
|
|
6
|
+
from linkedin_mcp_zero.config.defaults import CACHE_TTL_SECONDS
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
model_config = SettingsConfigDict(
|
|
11
|
+
env_prefix="LINKEDIN_MCP_",
|
|
12
|
+
env_file=(".env",),
|
|
13
|
+
extra="ignore",
|
|
14
|
+
populate_by_name=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
transport: str = "stdio"
|
|
18
|
+
host: str = "127.0.0.1"
|
|
19
|
+
port: int = 8000
|
|
20
|
+
log_level: str = "INFO"
|
|
21
|
+
timeout_seconds: float = 15
|
|
22
|
+
cache_ttl_seconds: int = CACHE_TTL_SECONDS
|
|
23
|
+
enable_voyager: bool = False
|
|
24
|
+
http_proxy: str | None = Field(default=None, validation_alias="HTTP_PROXY")
|
|
25
|
+
https_proxy: str | None = Field(default=None, validation_alias="HTTPS_PROXY")
|
|
26
|
+
data_dir: str | None = None
|
|
27
|
+
allowed_resume_dirs: list[str] | None = None
|
|
28
|
+
cdp_url: str = "http://127.0.0.1:9222"
|
|
29
|
+
browser_idle_seconds: int = 300
|
|
30
|
+
enable_browser: bool = False
|
|
31
|
+
enable_patchright_fallback: bool = False
|
|
32
|
+
browser_user_data_dir: str | None = None
|
|
33
|
+
exact_token_count: bool = False
|
|
34
|
+
token_count_model: str = "claude-sonnet-4-5"
|
|
35
|
+
anthropic_api_key: str | None = Field(default=None, validation_alias="ANTHROPIC_API_KEY")
|
|
36
|
+
li_at: str | None = Field(default=None, validation_alias="LI_AT")
|
|
37
|
+
api_key: str | None = Field(default=None, validation_alias="API_KEY")
|
|
38
|
+
cors_allowed_origins: list[str] = Field(default=["*"], validation_alias="CORS_ALLOWED_ORIGINS")
|
|
39
|
+
rate_limit_per_minute: int = Field(default=60, validation_alias="RATE_LIMIT_PER_MINUTE")
|
|
40
|
+
oauth_metadata_url: str | None = None
|
|
41
|
+
oauth_client_id: str | None = None
|
|
42
|
+
oauth_client_secret: str | None = None
|
|
43
|
+
oauth_server_url: str | None = None
|