A modular MCP (Model Context Protocol) server stack exposing EspoCRM and other optional tools to MCP clients such as AI coding agents, MCP enabled AI tools, and agent such as OpenClaw.
[MCP client]
|
| HTTP streamable-http / SSE port 8000
v
[ mcp-proxy ] ghcr.io/tbxark/mcp-proxy
|
|-- stdio: github, filesystem, fetch (spawned inside proxy)
|-- SSE: http://mcp-espocrm:8001/sse
All inter-container traffic is on the internal Docker network mcp-net. Only
mcp-proxy binds a host port (8000).
Spin up a container and immediately use as is. Built in mcp-proxy so you can add other mcp-servers if desired.
Supports standard as well as your custom entities. All entities in the allow list get the five core tools registered automatically:
- find
- get
- create
- update
- delete
How it works: At startup, server.py reads the MCP_ENTITY_ALLOW list and mounts any tools/*.py
file whose name matches an allowed entity. Entities in the allow list with no custom file
get the five core tools registered automatically. Adding a new entity requires adding it
to MCP_ENTITY_ALLOW (and optionally dropping a custom file in tools/) then restarting.
Fields for each entity are auto-discovered.
How it Works:
Before writing a record, normalize_fields() fetches the entity's field definitions from
the EspoCRM Metadata API (/api/v1/Metadata). This allows the server to:
- Detect special write formats -
emailAddressandphoneNumberfields require a structured*Dataarray payload rather than a plain string; the server handles this transparently. - Merge multiEnum fields - multiEnum fields (arrays of selected values) are read first
so that a write can add or remove individual values without clobbering the rest. The
caller passes a boolean dict (
{"Blog": true, "Newsletter": false}) and the server computes the correct merged array. - Metadata caching - field definitions are fetched once per entity per process lifetime and cached in memory, so the extra API call is paid only on the first write to each entity type.
Set the entities you want to expose in the MCP server in the environment MCP_ENTITY_ALLOW setting. Only the entities listed here will exposed as tools.
Use your defined Api User and associated permissions to limit access to the crm system. Set the use api key in the environment ESPOCRM_API_KEY setting.
Log output includes timestamp, level, and message. The tool call decorator logs every invocation (function name + parameters) at INFO level and errors at ERROR level. Noisy internal loggers (fastmcp, mcp, uvicorn request access) are suppressed to keep the log readable at INFO level.
Control the level of logging - Log level is controlled by the LOG_LEVEL environment variable (default: INFO).
Set it in docker-compose.yml under mcp-espocrm > environment:
- LOG_LEVEL=info # error | warning | info | debugView mcp-espocrm logs with:
docker compose logs -f mcp-espocrm├── docker-compose.yml # main stack definition
├── docker-compose.override.yml # host-specific overrides (bind mounts, etc.)
├── config.json # proxy upstream config (copy from config.json.example)
├── config.json.example
├── .env # secrets - never committed
├── .env.example # template with all supported variables
├── Dockerfile # builds the mcp-espocrm image
├── README.md
└── data/ # bind-mounted into proxy at /data (filesystem server root)
├── Dockerfile
├── requirements.txt
├── server.py # entrypoint: mounts tool modules filtered by allow list
├── .env.example
├── tests/
│ ├── conftest.py
│ └── test_accounts.py
└── tools/
├── config.py # reads MCP_ENTITY_ALLOW; defaults to 5 standard entities
├── entity.py # entity_find/get/create/update/delete helpers + register_entity()
├── espo_fields.py # field normalization (email, phone, multiEnum)
├── log.py # log_tool_call decorator
├── accounts.py # Account - custom: find_by_domain, find_by_name
├── contacts.py # Contact - custom: find_by_email
├── leads.py # Lead - custom: convert (stub)
└── <your-entity>.py # add custom entities here (see Adding Your Own Entity)
git clone <repo-url>
cd mcp-host
cp .env.example .env
cp config.json.example config.json
docker compose up -dCopy .env.example to .env and fill in values. Never commit .env.
| Variable | Description |
|---|---|
ESPOCRM_URL |
Full URL to your EspoCRM instance (e.g. https://crm.example.com) |
ESPOCRM_API_KEY |
EspoCRM API User key - create under Admin > API Keys |
MCP_ENTITY_ALLOW |
Comma-separated entity names to expose (default: Account,Contact,Lead,Opportunity,Task) |
Copy config.json.example to config.json. Defines all upstream MCP servers exposed
by the proxy. The example includes only the EspoCRM server. To add off-the-shelf stdio
servers (e.g. GitHub, filesystem), add entries under mcpServers with command/args
and restart the proxy - no new container needed.
Optional. Create this file for host-specific overrides such as extra bind mounts or a custom build context. See the Merging into an existing stack section below.
The EspoCRM server is accessible at:
| Server | Path | Description |
|---|---|---|
| EspoCRM | /espocrm/mcp |
CRM contacts, accounts, leads, and custom entities |
Additional servers can be added to config.json - each gets its own path under port 8000.
If you already have an mcp-proxy running, you can add just the EspoCRM container:
- Add the
mcp-espocrmservice fromdocker-compose.ymlto your own compose file. - Add the EspoCRM entry from
config.json.exampleto your proxy's config. - Build and start:
docker compose up -d --build mcp-espocrmthen restart your proxy.
The mcp-espocrm container has no dependency on the proxy - it just exposes SSE on port 8001
and can be pointed to from any MCP proxy that supports SSE upstreams.
Exposed tool names follow the pattern {namespace}_{operation} or {namespace}_{operation}_{description}.
- namespace - the module filename stem (e.g.
contacts,accounts). FastMCP prepends this automatically when the module is mounted. - operation - the Python function name inside the module. Use
findfor all search operations. Other verbs:get,create,update,delete. - description (optional) - appended when the operation alone is ambiguous, e.g.
find_by_email,find_blog_new.
So a function find_by_email in tools/contacts.py becomes the tool contacts_find_by_email.
Each module exposes a FastMCP instance named mcp and is auto-discovered at startup provided the entity name appears in MCP_ENTITY_ALLOW.
Every entity in MCP_ENTITY_ALLOW automatically gets these five tools via register_entity()
in tools/entity.py. The tool name prefix is the lowercase entity filename stem.
| Tool | Exposed as (example: accounts) | Description |
|---|---|---|
find(query="", filters=[], fields=[], ...) |
accounts_find |
Name-contains search + optional structured filters |
get(record_id) |
accounts_get |
Fetch a single record by EspoCRM ID |
create(fields) |
accounts_create |
Create a record; handles email/phone/multiEnum field formats |
update(record_id, fields) |
accounts_update |
Update fields; pass only fields to change |
delete(record_id) |
accounts_delete |
Delete a record by ID |
find filters format: list of dicts with attribute, type, and optional value keys,
e.g. {"attribute": "industry", "type": "equals", "value": "Technology"}.
| Entity | Tool | Description |
|---|---|---|
| accounts | accounts_find_by_domain |
Find by website domain; strips protocol/www prefix |
| accounts | accounts_find_by_name |
Ranked name match (exact > starts-with > contains) |
| contacts | contacts_find_by_email |
Exact email address lookup |
| leads | leads_convert |
Convert lead to contact/account (stub) |
Both standard EspoCRM entities (e.g. Opportunities, Cases) and fully custom entities
follow the same pattern.
Create one file per entity under tools/ and restart - no other registration is required.
Expand the core tools for any entity by adding your own custom functions. See the examples included for accounts, contacts.
Use the filename stem as the namespace (it becomes the tool name prefix). Call
register_entity() to get the pre-defined five core tools, then add any custom tools by following the instructions below.
import httpx
from fastmcp import FastMCP
from tools.log import log_tool_call
from tools.entity import register_entity, _base_url, _headers
mcp = FastMCP("<entity>") # display name, does not affect tool names
register_entity(mcp, "<EntityApiName>") # registers find, get, create, update, delete
# --- custom tools (optional) ------------------------------------------------
@mcp.tool()
@log_tool_call
async def my_custom_tool(some_param: str) -> dict:
# Add entity-specific tools here.
async with httpx.AsyncClient() as client:
r = await client.get(f"{_base_url()}/api/v1/<EntityApiName>",
headers=_headers(), params={"someParam": some_param})
r.raise_for_status()
return r.json()Replace <EntityApiName> with the EspoCRM REST entity name (singular, e.g. Opportunity,
Case, Meeting). The filename stem (e.g. opportunities) becomes the tool name prefix
(e.g. opportunities_find, opportunities_get).
cd ~/dockerdata/mcp-host
docker compose up -d --force-recreate mcp-espocrm
docker compose up -d --force-recreate mcp-proxyThe proxy must also be restarted to pick up the new tool names.
If the entity is specific to your EspoCRM instance and you do not want it committed to a
shared repo, add it to tools/.gitignore:
# instance-specific entity files
registrations.py
my_custom_entity.py
The auto-discovery mechanism picks them up regardless of whether they are tracked by git.
# Verify a server is responding:
curl -s -X POST http://localhost:8000/espocrm/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \
| python3 -c "import sys,json; [print(t['name']) for t in json.load(sys.stdin)['result']['tools']]"- Restart after code changes:
docker compose up -d --force-recreate(docker compose restartdoes not re-read compose YAML or apply healthcheck ordering) - Rebuild after dependency changes:
docker compose up -d --build mcp-espocrm - Logs:
docker compose logs -f mcp-espocrm - mcp-proxy caches the tool list from upstream servers at connect time - restart the proxy after adding new tools to mcp-espocrm