{
  "markdown": "# LinkedIn MCP Server with CRM (Salesbot)\n\n> **What is this?** `linkedin-mcp-server-salesbot` is a **Model Context Protocol - MCP server for AI‑assisted LinkedIn relationship operations**. It lets AI assistants — **Claude Desktop, ChatGPT, Cursor** — help you research and organize professional contacts, draft deeply personalized messages **for your review and approval**, sync inbox conversations, enrich profiles, and pull web context — all under your direction. It's built for **hyper‑targeted, meaningful outreach** (find 5 ideal contacts, read their recent posts, write 5 thoughtful notes), **not bulk blasting**. Every send is gated by **human‑in‑the‑loop approval** and **enforced, server‑side daily/hourly safety thresholds** that keep your LinkedIn account within safe limits.\n\nIt runs as a Supabase Edge Function (Deno + [Hono](https://hono.dev) + [mcp-lite](https://www.npmjs.com/package/mcp-lite)) exposing the MCP **Streamable HTTP** transport. LinkedIn actions go through a third‑party LinkedIn integration provider; LinkedIn credentials are never stored by the AI.\n\n- **Keywords:** model context protocol, mcp server, linkedin api, linkedin automation, claude desktop, cursor, ai agents, sales automation.\n- **Compatible clients:** Claude Desktop, Claude API/MCP, Cursor, any MCP Streamable‑HTTP client.\n\n## Quick facts\n\n| | |\n|---|---|\n| **Endpoint** | `https://app.salesbot.cz/api/mcp` |\n| **Transport** | MCP Streamable HTTP (POST + SSE) |\n| **Auth header** | `x-mcp-api-key: sb_mcp_…` (a Supabase JWT in `Authorization` also works) |\n| **Tool count** | 49 |\n| **License** | MIT |\n\n## How do I connect? (Claude Desktop / Cursor)\n\nAdd this to your MCP client config. Get the `sb_mcp_…` key in the Salesbot app under **Settings → MCP**.\n\n```json\n{\n  \"mcpServers\": {\n    \"linkedin-automation\": {\n      \"url\": \"https://app.salesbot.cz/api/mcp\",\n      \"headers\": {\n        \"x-mcp-api-key\": \"sb_mcp_YOUR_API_KEY\",\n        \"Accept\": \"application/json, text/event-stream\"\n      }\n    }\n  }\n}\n```\n\n> **Important:** send the key in the **`x-mcp-api-key`** header, **not** `Authorization: Bearer`. The Supabase API gateway rejects unknown Bearer tokens before they reach the server.\n\n## Authentication\n\n- **MCP API key** (`sb_mcp_…`) — long‑lived; generated in the Salesbot app, stored only as a SHA‑256 hash. Send in `x-mcp-api-key`.\n- **Supabase JWT** — a signed‑in user session token in `Authorization: Bearer`.\n- An active subscription/trial is required.\n\n## How do I authenticate LinkedIn?\n\nThe AI can do it without leaving the chat:\n\n1. Call `get_linkedin_status` — reports whether LinkedIn is connected/active/blocked.\n2. If not connected, call `connect_linkedin` — returns a white‑labeled `https://auth.salesbot.cz/…` link. The user opens it, completes LinkedIn login, done.\n\nOr connect in the app: **Settings → LinkedIn → Connect**.\n\n## Tools\n\nEach tool returns text content; errors return `{ \"ok\": false, \"code\": \"<CODE>\", \"error\": \"<message>\" }`.\n\n### Connection\n```json\n{ \"name\": \"get_linkedin_status\", \"input\": { \"profile_id\": \"uuid (optional)\" } }\n{ \"name\": \"connect_linkedin\",   \"input\": { \"profile_id\": \"uuid (optional)\", \"reconnect\": \"boolean (optional)\" } }\n```\n\n### Lead discovery\n```json\n{ \"name\": \"search_linkedin_people\",    \"input\": { \"title\": \"string (required)\", \"location\": \"string\", \"locationId\": \"string\", \"network\": \"['S'|'O']\", \"limit\": \"number 1-50\" } }\n{ \"name\": \"search_google_xray\",        \"input\": { \"jobTitle\": \"string (required)\", \"location\": \"string\", \"keywords\": \"string[]\", \"excludeWords\": \"string[]\", \"limit\": \"number 1-100\" } }\n{ \"name\": \"search_linkedin_navigator\", \"input\": { \"search_url\": \"string (required)\", \"limit\": \"number 1-100\" } }\n{ \"name\": \"search_job_postings\",       \"input\": { \"keywords\": \"string (required)\", \"location\": \"string\", \"locationId\": \"string\", \"seniority\": \"string[]\", \"job_type\": \"string[]\", \"presence\": \"string[]\", \"date_posted\": \"number\", \"easy_apply\": \"boolean\", \"limit\": \"number 1-50\" } }\n{ \"name\": \"search_web\",                \"input\": { \"query\": \"string (required)\", \"limit\": \"number 1-30\", \"country\": \"string (default cz)\", \"language\": \"string (default cs)\" } }\n{ \"name\": \"get_job_posting_details\",   \"input\": { \"job_id\": \"string (required)\" } }\n{ \"name\": \"scrape_website\",            \"input\": { \"url\": \"string (required)\", \"max_chars\": \"number (default 8000, max 20000)\" } }\n```\n`search_google_xray` saves the profiles it finds into a \"Google X-Ray\" contact list (deduplicated) and returns their `contact_id`s — ready to enrich, add to a campaign, or push into the CRM.\n\n`search_job_postings` searches LinkedIn job postings via the connected account (Classic search, no Recruiter needed). Returns job offers with company info — great for finding companies actively hiring for a specific role. Combine with `search_linkedin_people` to find the hiring manager.\n\n`search_web` is a general-purpose Google search (not restricted to LinkedIn). Use Google operators like `site:jobs.cz`, `intitle:`, `OR` to search job portals, company websites, or news. Results are NOT saved to contacts — this is a research/discovery tool.\n\n`get_job_posting_details` takes a `job_id` from `search_job_postings` and returns the full posting — most importantly `hiring_team`, the recruiter or hiring manager who posted the role, with their LinkedIn id and whether a free InMail is available. Also returns `applicants_counter` / `views_counter` as urgency signals. Typical flow: `search_job_postings` → `get_job_posting_details` → `enrich_contacts` → campaign.\n\n### Contacts\n```json\n{ \"name\": \"upsert_linkedin_contact\", \"input\": { \"profile_url\": \"string (required)\", \"full_name\": \"string\", \"company\": \"string\", \"position\": \"string\", \"headline\": \"string\" } }\n{ \"name\": \"get_contact_profile\", \"input\": { \"contact_id\": \"uuid (required)\" } }\n{ \"name\": \"list_lead_lists\",    \"input\": {} }\n{ \"name\": \"list_contacts\",       \"input\": { \"list_id\": \"uuid (required)\", \"limit\": \"number\", \"offset\": \"number\" } }\n{ \"name\": \"enrich_contacts\",     \"input\": { \"contact_ids\": \"uuid[] (required, max 8)\", \"profile_id\": \"uuid (optional)\" } }\n```\n`upsert_linkedin_contact` is the idempotent path for an exact, already-known LinkedIn profile URL. It creates the contact in the `CRM Imports` list or returns the existing `contact_id`, so CRM integrations can safely call it before `add_contacts_to_campaign` without relying on Google search.\n\n`list_lead_lists` returns each contact group's `list_id`, name, description and contact count. Pass a returned `list_id` to `list_contacts`.\n\nTypical contact workflow: `list_lead_lists` → `list_contacts` → `add_contacts_to_campaign`. Contacts still belong to a lead list, but adding an existing contact to a campaign only requires its `contact_id` and the target `campaign_id`.\n\n`search_linkedin_people` and `search_linkedin_navigator` return raw LinkedIn search results. They do not persist contacts; call `upsert_linkedin_contact` for each profile you want to save or add to a campaign.\n\n`enrich_contacts` scrapes each contact's full LinkedIn profile via the connected account (headline, location, current company & position, full work history, education, skills) and saves it onto the contact. Great right after `search_google_xray`.\n\n### Campaigns\n```json\n{ \"name\": \"list_campaigns\",           \"input\": { \"status\": \"draft|running|paused|completed|stopped (optional)\" } }\n{ \"name\": \"create_campaign\",          \"input\": { \"name\": \"string (required)\", \"profile_id\": \"uuid (required)\", \"description\": \"string\", \"daily_limit\": \"number\", \"sender_context\": \"string\", \"steps\": \"[{ action: 'connect'|'message'|'visit', delay_hours, use_ai, ai_prompt, ai_template, send_without_message }] (required)\" } }\n{ \"name\": \"update_campaign_settings\", \"input\": { \"campaign_id\": \"uuid (required)\", \"name\": \"string\", \"description\": \"string\", \"daily_limit\": \"number\", \"sender_context\": \"string\", \"auto_approve_messages\": \"boolean\", \"status\": \"running|paused|draft|stopped\" } }\n{ \"name\": \"start_campaign\",           \"input\": { \"campaign_id\": \"uuid (required)\" } }\n{ \"name\": \"stop_campaign\",            \"input\": { \"campaign_id\": \"uuid (required)\" } }\n{ \"name\": \"add_contacts_to_campaign\", \"input\": { \"campaign_id\": \"uuid (required)\", \"contact_ids\": \"uuid[] (required)\" } }\n```\n\n### AI messaging (write → approve → send)\n```json\n{ \"name\": \"generate_campaign_message\", \"input\": { \"campaign_contact_id\": \"uuid (required)\", \"step_id\": \"uuid (required)\", \"custom_instructions\": \"string\" } }\n{ \"name\": \"list_pending_approvals\",    \"input\": { \"campaign_id\": \"uuid\", \"limit\": \"number\" } }\n{ \"name\": \"approve_message\",           \"input\": { \"campaign_contact_id\": \"uuid (required)\", \"edited_messages\": \"[{step_id, message}]\", \"skip_gpt_check\": \"boolean\" } }\n{ \"name\": \"reject_message\",            \"input\": { \"campaign_contact_id\": \"uuid (required)\", \"reason\": \"string (required)\" } }\n```\n\n### Direct LinkedIn actions\n```json\n{ \"name\": \"send_connection_request\", \"input\": { \"linkedin_id\": \"string (required)\", \"profile_id\": \"uuid (required)\", \"contact_id\": \"uuid\" } }\n{ \"name\": \"send_linkedin_message\",   \"input\": { \"linkedin_id\": \"string (required)\", \"message\": \"string ≤5000 (required)\", \"profile_id\": \"uuid (required)\" } }\n{ \"name\": \"publish_linkedin_post\",   \"input\": { \"profile_id\": \"uuid (required)\", \"text\": \"string ≤3000 (required)\", \"external_link\": \"string\", \"as_organization\": \"string\", \"auto_publish\": \"boolean\" } }\n{ \"name\": \"get_daily_limits\",        \"input\": { \"profile_id\": \"uuid (optional)\" } }\n```\n\n### Inbox (real‑time)\n```json\n{ \"name\": \"list_inbox_chats\",  \"input\": { \"profile_id\": \"uuid (optional)\", \"limit\": \"number 1-50\", \"cursor\": \"string\" } }\n{ \"name\": \"get_chat_messages\", \"input\": { \"chat_id\": \"string (required)\", \"profile_id\": \"uuid (optional)\", \"limit\": \"number 1-50\", \"cursor\": \"string\" } }\n{ \"name\": \"reply_to_chat\",     \"input\": { \"chat_id\": \"string (required)\", \"message\": \"string ≤5000 (required)\", \"profile_id\": \"uuid (optional)\" } }\n{ \"name\": \"mark_chat_read\",    \"input\": { \"chat_id\": \"string (required)\", \"profile_id\": \"uuid (optional)\" } }\n```\n\n### CRM (pipeline, notes, tasks, message store)\nThe CRM is a persistent pipeline separate from contacts. A lead enters it when added to a campaign, or when any of these tools first touch it. It also acts as a durable store for generated outreach copy: save email / LinkedIn drafts and follow-ups with `save_lead_message`, read them back with `list_lead_messages` or `get_lead_context`, then send them through the right channel's own MCP (e.g. Smartlead for email) — this server never sends them itself.\n```json\n{ \"name\": \"set_deal_stage\",     \"input\": { \"contact_id\": \"uuid (required)\", \"stage\": \"string (required)\", \"note\": \"string\" } }\n{ \"name\": \"log_crm_note\",       \"input\": { \"contact_id\": \"uuid (required)\", \"summary\": \"string (required)\", \"pain_points\": \"string[]\", \"sentiment\": \"positive|neutral|negative\" } }\n{ \"name\": \"save_lead_message\",  \"input\": { \"contact_id\": \"uuid (required)\", \"body\": \"string (required)\", \"channel\": \"email|linkedin\", \"kind\": \"string e.g. initial|followup\", \"subject\": \"string\", \"status\": \"draft|queued|sent\", \"message_id\": \"uuid (update existing)\" } }\n{ \"name\": \"list_lead_messages\", \"input\": { \"contact_id\": \"uuid (required)\", \"channel\": \"email|linkedin\", \"kind\": \"string\", \"limit\": \"number\" } }\n{ \"name\": \"create_task\",        \"input\": { \"title\": \"string (required)\", \"contact_id\": \"uuid\", \"due_at\": \"ISO 8601\", \"details\": \"string\" } }\n{ \"name\": \"list_tasks\",         \"input\": { \"status\": \"open|done|cancelled|all\", \"contact_id\": \"uuid\", \"limit\": \"number\" } }\n{ \"name\": \"complete_task\",      \"input\": { \"task_id\": \"uuid (required)\", \"status\": \"done|open|cancelled\" } }\n{ \"name\": \"get_lead_context\",   \"input\": { \"contact_id\": \"uuid (required)\", \"notes_limit\": \"number\" } }\n{ \"name\": \"update_contact\",     \"input\": { \"contact_id\": \"uuid (required)\", \"email\": \"string\", \"phone\": \"string\", \"location\": \"string\", \"company\": \"string\", \"position\": \"string\", \"headline\": \"string\" } }\n{ \"name\": \"set_lead_fields\",    \"input\": { \"contact_id\": \"uuid (required)\", \"fields\": \"object { field_key: value }\" } }\n{ \"name\": \"export_crm\",         \"input\": { \"limit\": \"number (default 5000, max 20000)\" } }\n```\n`get_lead_context` returns the full 360° context for a lead — profile, pipeline stage, **custom fields**, **saved outreach messages**, conversation summaries, open tasks, recent LinkedIn interactions and stage history.\n\n### CRM configuration (stages & custom fields)\nPipeline stages and custom fields are user-configurable.\n```json\n{ \"name\": \"list_crm_stages\",  \"input\": {} }\n{ \"name\": \"add_crm_stage\",    \"input\": { \"label\": \"string (required)\", \"color\": \"hex string\" } }\n{ \"name\": \"rename_crm_stage\", \"input\": { \"key\": \"string (required)\", \"label\": \"string\", \"color\": \"hex string\" } }\n{ \"name\": \"delete_crm_stage\", \"input\": { \"key\": \"string (required)\", \"reassign_to\": \"string\" } }\n{ \"name\": \"list_crm_fields\",  \"input\": {} }\n{ \"name\": \"add_crm_field\",    \"input\": { \"label\": \"string (required)\", \"type\": \"text|number|date|url\" } }\n{ \"name\": \"delete_crm_field\", \"input\": { \"key\": \"string (required)\" } }\n```\n\n## Example call\n\nRequest (MCP `tools/call`):\n\n```json\n{ \"jsonrpc\": \"2.0\", \"id\": 1, \"method\": \"tools/call\",\n  \"params\": { \"name\": \"get_daily_limits\", \"arguments\": {} } }\n```\n\nSuccess result content (JSON inside the text part):\n\n```json\n{ \"profile_active\": true,\n  \"limits\": { \"connections\": { \"used\": 0, \"limit\": 30, \"effective_limit\": 30 },\n              \"messages\": { \"used\": 0, \"limit\": 40, \"effective_limit\": 40 } } }\n```\n\nError result content:\n\n```json\n{ \"ok\": false, \"code\": \"ACCOUNT_NOT_CONNECTED\", \"error\": \"Profile has no connected LinkedIn account.\" }\n```\n\n## Error codes\n\n| Code | Meaning |\n|------|---------|\n| `AUTH_MISSING` / `AUTH_INVALID` / `AUTH_EXPIRED` | missing / wrong / expired key |\n| `SUBSCRIPTION_REQUIRED` | trial expired or no active plan |\n| `RATE_LIMITED` | too many MCP requests — slow down |\n| `ACCOUNT_NOT_CONNECTED` | profile has no connected LinkedIn (call `connect_linkedin`) |\n| `ACCOUNT_BLOCKED` | LinkedIn restricted the account (campaigns auto‑paused) |\n| `PROFILE_INACTIVE` / `PROFILE_NOT_FOUND` / `ACCESS_DENIED` | profile / ownership |\n| `DAILY_LIMIT_REACHED` / `HOURLY_LIMIT_REACHED` | quota reached |\n| `OUTSIDE_ALLOWED_HOURS` | outside the account's sending window |\n| `BLACKLISTED` | target company/domain blacklisted |\n| `APPROVAL_REQUIRED` | queued for human approval before sending |\n| `SAFETY_BLOCKED` | text looks like prompt‑injection / unrequested URL |\n| `REPLY_LIMIT_REACHED` | already 2 AI replies in this conversation |\n| `SCRAPE_LIMIT_REACHED` | weekly web‑scrape quota reached |\n| `VALIDATION_ERROR` / `NOT_FOUND` / `UPSTREAM_ERROR` | bad input / not found / upstream failure |\n\n## Safety & responsible use\n\n**Built-in LinkedIn algorithmic protection and daily safety thresholds.** This is a relationship tool, not a mass-mailer — it's designed to send a few highly personalized, human-approved messages, and the server actively prevents bulk abuse:\n\n- Per‑account **daily limits** with gradual ramp‑up for new accounts; per‑hour MCP throttle; a general per‑user request rate limit.\n- **Human‑in‑the‑loop** approval queue for outbound actions (configurable).\n- **Allowed‑hours / days** windows and randomized anti‑detection delays.\n- **Prompt‑injection defense:** untrusted CRM/inbox text is treated as data; outbound text is scanned before sending.\n- **Inbox:** max 2 AI replies per conversation (anti‑overflow); replies are injection‑scanned.\n- **Account protection:** on a LinkedIn block (provider 403) campaigns auto‑pause and the user is emailed.\n\n## FAQ\n\n**Which AI clients work?** Any MCP Streamable‑HTTP client — Claude Desktop, the Claude API, Cursor, and similar.\n\n**Why `x-mcp-api-key` and not `Authorization`?** The Supabase gateway validates `Authorization` bearer tokens and rejects unknown ones; the custom header passes through untouched.\n\n**Does the AI see my LinkedIn password?** No. Authentication happens through a hosted provider flow (white‑labeled at `auth.salesbot.cz`); the MCP server only uses an account handle.\n\n**Can the AI send messages without me?** Only if you disable approval. By default outbound actions are queued for human approval.\n\n**Is it safe for my LinkedIn account?** Daily/hourly limits, ramp‑up, allowed‑hours, randomized delays, and auto‑pause on a detected block are all enforced server‑side.\n\n## Deploy\n\nRuns on the Salesbot Supabase backend. With the [Supabase CLI](https://supabase.com/docs/guides/cli):\n\n```bash\nsupabase functions deploy mcp-server --no-verify-jwt --project-ref <your-project-ref>\n```\n\nRequired function secrets: `SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_ANON_KEY`, the LinkedIn‑provider credentials, `CRON_SECRET`, `APP_URL`. The server does its own auth, hence `--no-verify-jwt`.\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n",
  "bytes": 16840,
  "sha": "322892dc8cb47df8b5173845db6d6dfd046890b901797f8268b1e40c5fa4323b",
  "repo_slug": "kubis010/linkedin-mcp-server-salesbot",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_cz_salesbot_linkedin_mcp_server_0817f92f/readme"
}