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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ This MCP server is **free** and **open source**, supported by [**Unipile**](http
| `get_company_posts` | Get recent posts from a company's LinkedIn feed | working |
| `search_companies` | Search for companies on LinkedIn by keywords | working |
| `get_company_employees` | List employees at a company from the /people/ page, with optional keyword filter | working |
| `search_jobs` | Search for jobs with keywords and location filters | working |
| `search_jobs` | Search for jobs with keywords and location filters; optional `output_mode`/`output_path` to save results to a file instead of (or alongside) returning them | working |
| `search_people` | Search for people by keywords, location, connection degree (1st/2nd/3rd), and current company | working |
| `get_job_details` | Get detailed information about a specific job posting | working |
| `get_job_details` | Get detailed information about a specific job posting; optional `output_mode`/`output_path` to save the posting to a file instead of (or alongside) returning it | working |
| `get_feed` | Get recent posts from the authenticated user's home feed | working |
| `close_session` | Close browser session and clean up resources | working |

Expand Down
4 changes: 2 additions & 2 deletions docs/docker-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ A Model Context Protocol (MCP) server that connects AI assistants to LinkedIn. A
- **Company Profiles**: Extract comprehensive company data, including the LinkedIn company URN id (used by LinkedIn's people-search `currentCompany` URL facet)
- **Company Employees**: List employees at a company with optional keyword filtering
- **Company Search**: Search for companies by keyword
- **Job Details**: Retrieve job posting information
- **Job Search**: Search for jobs with keywords and location filters
- **Job Details**: Retrieve job posting information, optionally saved to a file via `output_mode`/`output_path`
- **Job Search**: Search for jobs with keywords and location filters, optionally saved to a file via `output_mode`/`output_path`
- **People Search**: Search for people by keywords and location
- **Person Posts**: Get recent activity/posts from a person's profile
- **Company Posts**: Get recent posts from a company's LinkedIn feed
Expand Down
52 changes: 52 additions & 0 deletions linkedin_mcp_server/common_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

from __future__ import annotations

import json
import os
from datetime import UTC, datetime
from pathlib import Path
import re
import tempfile
from typing import Any, Literal


def slugify_fragment(value: str) -> str:
Expand Down Expand Up @@ -53,3 +55,53 @@ def secure_write_text(path: Path, content: str, mode: int = 0o600) -> None:
except BaseException:
os.unlink(tmp)
raise


def _render_result_text(result: dict[str, Any]) -> str:
"""Render a tool result dict as readable plain text for .txt/.md dumps."""
parts: list[str] = []
url = result.get("url")
if url:
parts.append(f"URL: {url}")
for name, text in (result.get("sections") or {}).items():
parts.append(f"\n## {name}\n{text}")
job_ids = result.get("job_ids")
if job_ids:
parts.append("\nJOB_IDS: " + ", ".join(job_ids))
return "\n".join(parts) + "\n"


def apply_output_mode(
result: dict[str, Any],
output_path: str | None,
output_mode: Literal["display", "file", "both"],
) -> dict[str, Any]:
"""Optionally persist *result* to disk and shape what is returned to the caller.

- ``display`` (default): return the full result, write nothing.
- ``file``: write to *output_path*, return a compact confirmation only.
- ``both``: write to *output_path* and return the full result.

File format follows the extension: ``.json`` dumps the full dict, anything
else writes a readable text rendering of url/sections/job_ids.
"""
if output_mode == "display":
return result
if not output_path:
raise ValueError("output_path is required when output_mode is 'file' or 'both'")

path = Path(output_path).expanduser()
if path.suffix == ".json":
secure_write_text(path, json.dumps(result, ensure_ascii=False, indent=2))
else:
secure_write_text(path, _render_result_text(result))

if output_mode == "both":
return result
confirmation: dict[str, Any] = {"saved_path": str(path)}
if "url" in result:
confirmation["url"] = result["url"]
if "job_ids" in result:
confirmation["job_ids"] = result["job_ids"]
confirmation["sections"] = sorted((result.get("sections") or {}).keys())
return confirmation
23 changes: 20 additions & 3 deletions linkedin_mcp_server/tools/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
"""

import logging
from typing import Annotated, Any
from typing import Annotated, Any, Literal

from fastmcp import Context, FastMCP
from pydantic import Field

from linkedin_mcp_server.common_utils import apply_output_mode
from linkedin_mcp_server.config.schema import DEFAULT_TOOL_TIMEOUT_SECONDS
from linkedin_mcp_server.core.exceptions import AuthenticationError
from linkedin_mcp_server.dependencies import get_ready_extractor, handle_auth_error
Expand All @@ -33,6 +34,8 @@ def register_job_tools(
async def get_job_details(
job_id: str,
ctx: Context,
output_path: str | None = None,
output_mode: Literal["display", "file", "both"] = "display",
extractor: Any | None = None,
) -> dict[str, Any]:
"""
Expand All @@ -41,6 +44,12 @@ async def get_job_details(
Args:
job_id: LinkedIn job ID (e.g., "4252026496", "3856789012")
ctx: FastMCP context for progress reporting
output_path: Where to save the result when output_mode is file/both.
Extension drives format: .json dumps the full dict, anything
else writes a readable text rendering.
output_mode: 'display' (default) returns content and writes nothing;
'file' writes to output_path and returns a compact confirmation;
'both' writes to output_path and returns the full content.

Returns:
Dict with url, sections (name -> raw text), and optional references.
Expand All @@ -60,7 +69,7 @@ async def get_job_details(

await ctx.report_progress(progress=100, total=100, message="Complete")

return result
return apply_output_mode(result, output_path, output_mode)

except AuthenticationError as e:
try:
Expand Down Expand Up @@ -88,6 +97,8 @@ async def search_jobs(
work_type: str | None = None,
easy_apply: bool = False,
sort_by: str | None = None,
output_path: str | None = None,
output_mode: Literal["display", "file", "both"] = "display",
extractor: Any | None = None,
) -> dict[str, Any]:
"""
Expand All @@ -106,6 +117,12 @@ async def search_jobs(
work_type: Filter by work type, comma-separated (on_site, remote, hybrid)
easy_apply: Only show Easy Apply jobs (default false)
sort_by: Sort results (date, relevance)
output_path: Where to save the result when output_mode is file/both.
Extension drives format: .json dumps the full dict, anything
else writes a readable text rendering.
output_mode: 'display' (default) returns content and writes nothing;
'file' writes to output_path and returns a compact confirmation
(url + job_ids + section names); 'both' writes and returns full.

Returns:
Dict with url, sections (name -> raw text), job_ids (list of
Expand Down Expand Up @@ -140,7 +157,7 @@ async def search_jobs(

await ctx.report_progress(progress=100, total=100, message="Complete")

return result
return apply_output_mode(result, output_path, output_mode)

except AuthenticationError as e:
try:
Expand Down
4 changes: 2 additions & 2 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,11 @@
},
{
"name": "get_job_details",
"description": "Retrieve specific job posting details using LinkedIn job IDs"
"description": "Retrieve specific job posting details using LinkedIn job IDs, with optional output_mode/output_path to save the result to a file"
},
{
"name": "search_jobs",
"description": "Search for jobs with filters like keywords and location"
"description": "Search for jobs with filters like keywords and location, with optional output_mode/output_path to save results to a file"
},
{
"name": "search_people",
Expand Down
77 changes: 77 additions & 0 deletions tests/test_common_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Tests for linkedin_mcp_server.common_utils output helpers."""

import json

import pytest

from linkedin_mcp_server.common_utils import apply_output_mode


def _sample_result() -> dict:
return {
"url": "https://www.linkedin.com/jobs/view/12345/",
"sections": {"job_posting": "Software Engineer\nGreat opportunity"},
"job_ids": ["12345", "67890"],
}


class TestApplyOutputMode:
def test_display_returns_full_result_and_writes_nothing(self, tmp_path):
result = _sample_result()
target = tmp_path / "out.json"

returned = apply_output_mode(result, str(target), "display")

assert returned is result
assert not target.exists()

def test_file_writes_json_and_returns_confirmation(self, tmp_path):
result = _sample_result()
target = tmp_path / "job.json"

returned = apply_output_mode(result, str(target), "file")

# Confirmation carries section names, not the full sections payload.
assert returned["saved_path"] == str(target)
assert returned["url"] == result["url"]
assert returned["job_ids"] == result["job_ids"]
assert returned["sections"] == ["job_posting"]
assert returned["sections"] != result["sections"]

on_disk = json.loads(target.read_text())
assert on_disk == result

def test_file_writes_text_for_non_json_extension(self, tmp_path):
result = _sample_result()
target = tmp_path / "job.md"

apply_output_mode(result, str(target), "file")

body = target.read_text()
assert "URL: https://www.linkedin.com/jobs/view/12345/" in body
assert "## job_posting" in body
assert "Software Engineer" in body
assert "JOB_IDS: 12345, 67890" in body

def test_both_writes_file_and_returns_full_result(self, tmp_path):
result = _sample_result()
target = tmp_path / "job.json"

returned = apply_output_mode(result, str(target), "both")

assert returned is result
assert json.loads(target.read_text()) == result

def test_file_mode_requires_output_path(self):
with pytest.raises(ValueError, match="output_path is required"):
apply_output_mode(_sample_result(), None, "file")

def test_expands_user_home(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
result = _sample_result()

returned = apply_output_mode(result, "~/saved-job.json", "file")

written = tmp_path / "saved-job.json"
assert written.exists()
assert returned["saved_path"] == str(written)
47 changes: 47 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,53 @@ async def test_search_jobs(self, mock_context):
assert "search_results" in result["sections"]
assert "pages_visited" not in result

async def test_get_job_details_writes_file(self, mock_context, tmp_path):
"""output_mode='file' persists the result and returns a confirmation."""
expected = {
"url": "https://www.linkedin.com/jobs/view/12345/",
"sections": {"job_posting": "Software Engineer"},
}
mock_extractor = _make_mock_extractor(expected)

from linkedin_mcp_server.tools.job import register_job_tools

mcp = FastMCP("test")
register_job_tools(mcp)

target = tmp_path / "job.json"
tool_fn = await get_tool_fn(mcp, "get_job_details")
result = await tool_fn(
"12345",
mock_context,
output_path=str(target),
output_mode="file",
extractor=mock_extractor,
)

assert result["saved_path"] == str(target)
# Confirmation lists section names, not the full text payload.
assert result["sections"] == ["job_posting"]
assert target.exists()

async def test_search_jobs_default_mode_returns_content(self, mock_context):
"""The default output_mode keeps the existing display behaviour."""
expected = {
"url": "https://www.linkedin.com/jobs/search/?keywords=python",
"sections": {"search_results": "Job 1\nJob 2"},
"job_ids": ["1", "2"],
}
mock_extractor = _make_mock_extractor(expected)

from linkedin_mcp_server.tools.job import register_job_tools

mcp = FastMCP("test")
register_job_tools(mcp)

tool_fn = await get_tool_fn(mcp, "search_jobs")
result = await tool_fn("python", mock_context, extractor=mock_extractor)
assert "search_results" in result["sections"]
assert "saved_path" not in result


class TestGetSidebarProfilesTool:
async def test_get_sidebar_profiles_success(self, mock_context):
Expand Down
Loading