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.
Files changed (46) hide show
  1. linkedin_mcp_zero/__init__.py +3 -0
  2. linkedin_mcp_zero/__main__.py +4 -0
  3. linkedin_mcp_zero/cache/__init__.py +3 -0
  4. linkedin_mcp_zero/cache/ttl_cache.py +41 -0
  5. linkedin_mcp_zero/config/__init__.py +3 -0
  6. linkedin_mcp_zero/config/autodetect.py +152 -0
  7. linkedin_mcp_zero/config/defaults.py +7 -0
  8. linkedin_mcp_zero/config/install.py +412 -0
  9. linkedin_mcp_zero/config/settings.py +43 -0
  10. linkedin_mcp_zero/engines/__init__.py +3 -0
  11. linkedin_mcp_zero/engines/browser.py +503 -0
  12. linkedin_mcp_zero/engines/matching.py +175 -0
  13. linkedin_mcp_zero/engines/multi_board.py +60 -0
  14. linkedin_mcp_zero/engines/public_api.py +208 -0
  15. linkedin_mcp_zero/engines/resume.py +171 -0
  16. linkedin_mcp_zero/engines/voyager.py +34 -0
  17. linkedin_mcp_zero/main.py +223 -0
  18. linkedin_mcp_zero/metrics/__init__.py +1 -0
  19. linkedin_mcp_zero/metrics/store.py +159 -0
  20. linkedin_mcp_zero/metrics/tracking.py +189 -0
  21. linkedin_mcp_zero/py.typed +1 -0
  22. linkedin_mcp_zero/scraping/__init__.py +1 -0
  23. linkedin_mcp_zero/scraping/guest_api.py +329 -0
  24. linkedin_mcp_zero/scraping/schema.py +32 -0
  25. linkedin_mcp_zero/server/__init__.py +1 -0
  26. linkedin_mcp_zero/server/app.py +727 -0
  27. linkedin_mcp_zero/server/catalog.py +341 -0
  28. linkedin_mcp_zero/server/middleware.py +95 -0
  29. linkedin_mcp_zero/storage/__init__.py +3 -0
  30. linkedin_mcp_zero/storage/db.py +111 -0
  31. linkedin_mcp_zero/storage/vector_db.py +83 -0
  32. linkedin_mcp_zero/utils/__init__.py +1 -0
  33. linkedin_mcp_zero/utils/circuit_breaker.py +58 -0
  34. linkedin_mcp_zero/utils/compress.py +73 -0
  35. linkedin_mcp_zero/utils/errors.py +17 -0
  36. linkedin_mcp_zero/utils/llm.py +67 -0
  37. linkedin_mcp_zero/utils/logging.py +19 -0
  38. linkedin_mcp_zero/utils/oauth.py +76 -0
  39. linkedin_mcp_zero/utils/rate_limit.py +25 -0
  40. linkedin_mcp_zero/utils/telemetry.py +82 -0
  41. linkedin_mcp_zero-0.3.0.data/data/linkedin_mcp_zero/py.typed +1 -0
  42. linkedin_mcp_zero-0.3.0.dist-info/METADATA +332 -0
  43. linkedin_mcp_zero-0.3.0.dist-info/RECORD +46 -0
  44. linkedin_mcp_zero-0.3.0.dist-info/WHEEL +4 -0
  45. linkedin_mcp_zero-0.3.0.dist-info/entry_points.txt +3 -0
  46. linkedin_mcp_zero-0.3.0.dist-info/licenses/LICENSE +17 -0
@@ -0,0 +1,3 @@
1
+ """LinkedIn MCP Zero package."""
2
+
3
+ __version__ = "0.3.0"
@@ -0,0 +1,4 @@
1
+ from linkedin_mcp_zero.main import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,3 @@
1
+ from linkedin_mcp_zero.cache.ttl_cache import TTLCache
2
+
3
+ __all__ = ["TTLCache"]
@@ -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,3 @@
1
+ from linkedin_mcp_zero.config.settings import Settings
2
+
3
+ __all__ = ["Settings"]
@@ -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
@@ -0,0 +1,3 @@
1
+ from linkedin_mcp_zero.engines.public_api import PublicAPIEngine
2
+
3
+ __all__ = ["PublicAPIEngine"]