LinkedIn & Sales NavigatorTutorial

Running a Sales Navigator MCP server: 22 tools, and isError: False on a failed call

Live protocol introspection of a Sales Navigator MCP server: a borrowed version string, and a failed call that reports isError: False.

What we ranLive introspection of unipile-linkedin-mcp @ a6a28cb: 22 tools, a server version borrowed from the SDK, and a failed API call that returns isError: False (source path unipile_linkedin.py:92-95), captured 2026-07-21.

Wiring an agent to Sales Navigator over MCP means installing a server, connecting over stdio, and calling tools/list for the real surface — not the README's. Do that against bhaktatejas922/unipile-linkedin-mcp @ a6a28cb, and the finding that matters most: a failed call comes back isError: False. An agent branching on that flag sees success. The server also reports the SDK's version as its own.

Two things before you read further. Kairon builds a competing Sales Navigator MCP server, so this is a rival's code, run rather than summarized from its README — the commands below are the same ones we ran, reproduce them yourself rather than take a vendor's word about its own category, ours included. And there is a hard boundary on what this establishes: no valid Unipile account was available, so every call here used a deliberately invalid key and stopped at Unipile's API boundary. No LinkedIn account was authenticated and no request reached LinkedIn — search, invitations and InMail were never actually run. What follows is what a live connection shows about the declared protocol surface and the server's behavior on failure, nothing about whether Sales Navigator automation works end to end.

On LinkedIn automation. LinkedIn's User Agreement prohibits automated access to the platform. This server automates it through a third-party API; what we ran used a deliberately invalid key and never reached LinkedIn, so nothing here measures LinkedIn behaviour — and none of it is permission, a safety guarantee, or advice to run it against a real account. Your account, your risk.

The method here is the same discipline as wiring Search Console to an agent through Application Default Credentials: run the actual commands and read the actual output, instead of trusting a description of what they do. Run: 2026-07-21T11:08:38Z11:10:22Z.

Setup: installing the server, and pinning a commit to audit

The README installs the server from PyPI — uvx unipile-linkedin-mcp (its recommended path), or pip install unipile-linkedin-mcp. Both expect two environment variables set first; the server raises if either is missing:

export UNIPILE_API_KEY="..."
export UNIPILE_BASE_URL="https://api13.unipile.com:14376/api/v1"

This post audits one pinned commita6a28cb — rather than whatever the PyPI release currently ships, so it runs the code from source instead. Clone the repo, check out that commit, and stand it up in a venv:

git clone https://github.com/bhaktatejas922/unipile-linkedin-mcp
cd unipile-linkedin-mcp && git checkout a6a28cb
python3 -m venv .venv
.venv/bin/pip install httpx "mcp[cli]"

Installed for this run: httpx 0.28.1, mcp 1.28.1.

The 22-tool surface, from tools/list, not the README

With the environment built, connect over stdio with the MCP Python SDK and call tools/list. This is the declared surface as the protocol reports it:

## server: unipile-linkedin v1.28.1
## protocol: 2025-11-25
## tools declared: 22

The Sales Navigator and write tools, with required-vs-optional resolved from each tool's inputSchema:

search_people_sales_nav(keywords?, location?, industry?, company?, past_company?,
                        network_distance?, profile_language?, tenure?, seniority_level?,
                        function?, company_headcount?, changed_jobs?, posted_on_linkedin?,
                        limit?: integer, cursor?)
send_invitation(provider_id: string, message?)
send_message(chat_id: string, text: string)
start_chat(attendees_ids: array, text: string)
send_inmail(attendees_ids: array, subject: string, text: string)
get_inmail_credits()

Every parameter of search_people_sales_nav is optional. A call with no arguments at all is schema-valid — nothing in the contract stops an agent from issuing an unfiltered search.

Filter values are IDs, not names. get_search_params(param_type, query) exists to resolve them, and its own description says why: "LinkedIn search filters require specific IDs (not names)." An agent that passes location="Argentina" straight into search_people_sales_nav is passing something the API will not match — it has to call get_search_params first and thread the resolved ID through.

The tools also declare their own prerequisites, in their descriptions rather than in any schema field:

ToolStated requirement (its own description)
search_people_sales_nav"requires Sales Nav subscription"
send_inmail"requires LinkedIn Premium or Sales Navigator", consumes credits

Two things you only see by connecting

A source read gets you tool names and signatures. These two only show up once the server is actually running and answering.

The version it reports isn't its own

## server: unipile-linkedin v1.28.1
$ grep -m1 '^version' pyproject.toml
version = "0.1.0"
$ pip show mcp | grep Version
Version: 1.28.1

The server advertises the MCP SDK's version — 1.28.1 — as its own, while its pyproject.toml at a6a28cb says 0.1.0. FastMCP falls back to the SDK's version when the server itself doesn't set one, so a client that pins behavior to serverInfo.version is reading the library it's built on, not the server that's running. Cosmetic on its own, until something downstream depends on it.

A failed call reports isError: False

Calling list_accounts with a deliberately invalid key (UNIPILE_API_KEY=invalid-key-for-schema-introspection):

$ call list_accounts   (UNIPILE_API_KEY=invalid-key-for-schema-introspection)
2026-07-21 08:10:24 - unipile-linkedin - ERROR - API Error: {"object":"Error","type":"api/resource_not_found","title":"Route Not Found","req_id":"req-4gc","status":404}

isError: False
{
  "error": "{\"object\":\"Error\",\"type\":\"api/resource_not_found\",\"title\":\"Route Not Found\",\"req_id\":\"req-4gc\",\"status\":404}",
  "status_code": 404
}

The mechanism is in the source. unipile_linkedin.py:92-95 handles every HTTP response the same way:

if response.status_code >= 400:
    error_text = response.text
    logger.error(f"API Error: {error_text}")
    return {"error": error_text, "status_code": response.status_code}

On any status >= 400 the server logs ERROR and then returns the error as a plain dict — it never raises. FastMCP has no exception to catch, so it wraps that dict as a successful tool result and sets the MCP isError flag to False. The error is a return value, not a raise, which is why the outcome is host-independent: this run used an invalid key, but any response >= 400 — wrong key, wrong host, a route that moved — comes back the same way, a non-error result carrying an error object. An agent branching on isError, which is what the flag exists for, sees success and hands the error object downstream as data. That is the failure mode that produces an agent confidently reporting results it never got.

This is the mirror image of a bug we found in our own server: there, failures collapsed into isError: true — over-reporting — while here a real failure returns isError: False — under-reporting. Same class of bug from opposite directions; in both, the flag can't be trusted.

What this run does not establish

  • Whether the LinkedIn-side calls work. No valid Unipile account was available, so every call above stopped at Unipile's API boundary. search_people_sales_nav, send_invitation and send_inmail were never exercised against a live account.
  • Rate limits or account risk. send_invitation's own description carries the tool author's figures — "~80-100/day for paid accounts, ~15/week for free" — quoted here because they're in the tool's docstring, not because they were measured in this run. Treat them as the author's claim, not as a verified or safe operating limit.
  • Cost. Unipile is a paid API; pricing was not investigated as part of this run.
  • Anything about other servers. Only unipile-linkedin-mcp at a6a28cb was connected to and called this way. Other write-capable servers in the same space were read, not executed — that source-level audit of the same repos is its own piece. More of these live runs land on the notes index as they finish.