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:38Z → 11: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 commit — a6a28cb — 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:
| Tool | Stated 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_invitationandsend_inmailwere 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-mcpata6a28cbwas 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.