Source

Meridian CRM MCP

A local MCP server that lets Claude read and write a Bitrix24 CRM, with tasks, telephony and website chats: 30 tools, writes locked to named funnels, redacted logs. The company is fictional.

  • Python
  • Model Context Protocol
  • Bitrix24
Repository
mattrguerra/meridian-crm-mcp
Branch
main
Files
23 files · 4 folders
Languages
Python 98% · Shell 1% · Batch 1%
Visibility
Private on GitHub, published here in full

Bitrix24 MCP Server

A local MCP server that lets Claude Desktop read and write a company's Bitrix24 CRM, tasks, telephony, and website chats over an inbound webhook.

The company is fictional. This server was built for, and used every day at, a real print and display shop. Here it serves Meridian Display Co., the invented company behind meridian-ops-board: this is the CRM side of the same system. Funnel names, field labels and examples are Meridian's; the engineering is the real server's.

This server performs writes. Lead writes, funnel-locked deal writes (including create_deal), and contact/company create/update are enabled by default. Set BITRIX_WRITES=off for a fully read-only server.

Deployment model

Local, per-machine, stdio: Claude Desktop launches server.py as a subprocess on each staff member's machine. There is no network listener, so the security boundary is the webhook secret in .env and the scopes you grant it. See "Security & opsec" below before distributing.

What it does

30 tools. Reads are unrestricted; writes are gated by BITRIX_WRITES (and deal writes are additionally locked to specific funnels).

Reads

Tool Purpose
search_crm Find leads/contacts/companies/deals by name, email, or phone
get_record Full detail on a record, incl. custom fields + an order summary
get_timeline Activity history (calls, emails, comments) + website chats
get_chat_transcript Website live-chat (Open Channel) conversations on a record
list_tasks Your open/overdue/completed tasks
pipeline_summary Exact per-stage counts (batched), with stage names
list_records List records by assignee, stage, field, funnel, or date
get_fields / find_field Field schema / search fields by label across entities
get_products Line items on a record (product, qty, price, total)
get_stage_history Stage movement with time-in-stage
get_call_history Recent telephony calls (with CALL_IDs and recording flags)
get_call_recording Recording URL/file + bound record for one call
list_pipelines Deal funnels (with stages) + smart processes

Writes (gated by BITRIX_WRITES)

Tool Scope of write
add_lead_comment, rename_lead, set_lead_fields Leads
add_deal_comment, set_deal_title, set_deal_stage, set_deal_fields, assign_deal, link_deal_contact, link_deal_company, create_deal Deals, write-enabled funnels only
create_contact, update_contact, create_company, update_company Parties, gated also by BITRIX_WRITE_PARTIES; create dup-checks first
transcribe_call Local transcription, opt-in via BITRIX_TRANSCRIBE

set_lead_fields / set_deal_fields take a {label: value} object and translate enum options, yes/no booleans, and dates automatically, e.g. {"Follow-up Status": "Quote Sent", "Rush Job": "yes"}.

Webhook permissions (required)

Create one Inbound webhook (Applications → Developer resources → Other → Inbound webhook) and enable exactly these scopes:

Scope Powers
crm all CRM reads + writes
user_brief assignee / author names (upgrade to user if names look thin)
task list_tasks
telephony get_call_history, get_call_recording, transcription
imopenlines + im website-chat read (commercial plan required)

Without imopenlines/im, chat tools degrade gracefully (timeline still works). Without telephony, the call tools return a clear "missing scope" message.

CoPilot note. Bitrix CoPilot's generated transcripts/summaries are not readable over REST, and a call's TRANSCRIPT_ID has no getter. "CoPilot-style" value here comes from Claude summarizing the data these tools expose (chat transcripts, recordings, timeline), not from reading CoPilot itself.

Security & opsec (read before distributing)

  • The webhook URL is a password. Anyone with it has the webhook user's full API access from anywhere (Bitrix cloud webhooks can't be IP-restricted).
    • Choose a webhook-owner model and stick to it:
      • Per-user (what the Windows install uses): each person's webhook lives in their own Bitrix account, so actions are attributable to them. Never copy one person's .env to another machine.
      • Dedicated service user: one non-admin "CRM Automation" account scoped to only what's needed: simplest least-privilege, but loses per-user audit.
    • Enable 2FA on whichever account owns the webhook.
    • chmod 600 .env on every machine. Never commit .env (it's gitignored).
    • Have a rotation plan: if a machine is lost, delete + recreate the webhook in Bitrix and update .env.
  • Token never logged. httpx request logging is silenced and all error paths redact the webhook URL.
  • Logs are PII-scrubbed by default. logs/calls.jsonl records tool calls for migration analysis; BITRIX_LOG_ARGS=redact (default) masks emails/phones and blanks customer names/free text. Use full only on a trusted solo machine; off to log no arguments. The log self-rotates past BITRIX_LOG_MAX_BYTES.
  • Untrusted content is fenced. Customer/visitor free text (record fields, email/chat bodies) is wrapped as untrusted data so the model treats it as data, never instructions. Keep per-write confirmation on in Claude Desktop.

Setup

cd meridian-crm-mcp
python3 -m venv .venv
source .venv/bin/activate          # Windows: .venv\Scripts\activate
pip install -r requirements.txt

cp .env.example .env
# paste your webhook URL and adjust settings
chmod 600 .env                     # macOS/Linux

Smoke test: python server.py should log a startup line and wait silently. Ctrl+C to stop. Then wire it into Claude Desktop's claude_desktop_config.json under mcpServers with absolute paths to the venv python and server.py, and restart Claude Desktop.

Checks

pip install -r requirements.txt pytest ruff
ruff check .
python -m py_compile server.py
pytest
BITRIX_WEBHOOK_URL=https://example.invalid/rest/1/x BITRIX_DATA_DIR=$(mktemp -d) \
  python server.py              # starts on stdio; Ctrl+C to stop
cd mcpb && ./build.sh           # mcpb validate + pack

The tests need no webhook and make no network calls: tests/conftest.py sets the environment before importing server.py and swaps bitrix_call for a recording fake, and bitrix_call itself runs against httpx.MockTransport. tests/tool_api.json records the 30 tools' names, docstrings and parameter schemas, so any change to what the model sees fails the suite until the contract file is updated on purpose. CI runs lint, compile and tests on Python 3.10 and 3.12.

Configuration

All settings live in .env (see .env.example for the annotated list): BITRIX_WEBHOOK_URL, BITRIX_LOCAL_TZ, BITRIX_WRITES, BITRIX_WRITE_FUNNELS, BITRIX_WRITE_PARTIES, BITRIX_LOG_ARGS, BITRIX_LOG_MAX_BYTES, BITRIX_TRANSCRIBE.

Architecture notes

  • Universal API. Reads use the crm.item.* family (entityTypeId: lead 1, deal 2, contact 3, company 4). The one exception is party create/update, which uses legacy crm.contact.* / crm.company.* because crm.item.add silently drops phone/email multifields.
  • Batched counts. pipeline_summary runs all per-stage counts in one rest.batch round-trip (chunked at 50). One shared httpx.AsyncClient pools connections across calls.
  • Semantic tool names (search_crm, not bitrix_crm_search) so the interface survives a future CRM migration; only the implementation swaps.
  • Schema drift tracking. Field schemas snapshot to ./schema/ (one dated file per entity per day) so form-builder changes are diffable over time. The snapshots are gitignored: they hold the live CRM's own field definitions.

Troubleshooting

  • BITRIX_WEBHOOK_URL not set: .env missing/empty.
  • insufficient_scope: add the missing scope to the webhook (see table).
  • Chat tools say insufficient_scope: add imopenlines + im (commercial plan required).
  • A deal write is "Refused… not a write-enabled funnel": the deal isn't in BITRIX_WRITE_FUNNELS; that's the funnel lock working as intended.
  • Custom field shows as UF_CRM_…: it's a custom field; get_record shows its label, and get_fields / find_field dump the schema.