lm-mcp-server
The LiquidMind MCP server. It exposes a small set of READ and PROPOSEtools to Claude (Cowork) and translates each tool call into an HTTP call againstthe existing Liquid-Mind-CRM API. It holds no business logic and no platformcredentials — it is a thin, authenticated router that sits in front of tools thatalready exist.
Claude Cowork ──HTTPS (bearer)──▶ lm-mcp ──HTTP (service token)──▶ Liquid-Mind-CRM API
(connector) (this) (truth ledger + Action_Queue)
Scope (v1)
- READ tools — pull from the truth ledger via the CRM API. No side effects.
list_clients,resolve_client,get_series_performance,get_campaign_performance,list_tasks,run_ad_review
- PROPOSE tools — write a row to the CRM
Action_Queue(status = 'proposed').They never touch a campaign.propose_bid_change,propose_budget_shift,propose_audience_rebuild,propose_campaign_state,propose_new_campaign
There is no EXECUTE in v1. Nothing this server does moves money — the worst acompromised token can do is read data and insert proposal rows a human must stillapprove. EXECUTE (apply_approved_action) is deliberately deferred (see Roadmap).
Layout
src/
index.ts Express + streamable-HTTP transport + incoming bearer auth
server.ts Builds the McpServer and registers tools
config.ts Env loading/validation (all endpoints are config vars)
auth.ts Bearer check for the /mcp endpoints
clients/
crmClient.ts Thin HTTP client for the CRM API
webToolsClient.ts Thin client for the WebPages PHP tools (reserved for EXECUTE)
tools/
read.ts READ tools
propose.ts PROPOSE tools
index.ts, util.ts
Dockerfile, docker-compose.yml, cloudflared/config.example.yml, .env.example
Run locally
cp .env.example .env # set MCP_AUTH_TOKEN and CRM_BASE_URL / CRM_SERVICE_TOKEN
npm install
npm run build
npm start # listens on :8930
curl localhost:8930/health
Run with Docker
cp .env.example .env # fill in values
docker compose up -d --build
Bind stays on 127.0.0.1:8930; put Cloudflare in front for the public hostname.
Cloudflare
Give it its own subdomain — lm-mcp.website.com — not a path under the website.
- Preferred: a Cloudflare Tunnel so the droplet needs no open inbound port.Either run the token-based sidecar (uncomment
cloudflaredindocker-compose.yml, setTUNNEL_TOKEN) or a locally-installedcloudflaredwithcloudflared/config.example.yml. - Do not put interactive Cloudflare Access / SSO in front of
/mcp— theclient is Anthropic-hosted, not a browser; an interactive login breaks theconnector. Use the app's bearer token for identity and Cloudflare for thenon-interactive layers (WAF, rate limiting, optional IP allowlist). - Disable caching for
lm-mcp.*and confirm streaming works end-to-end throughthe proxy.
The connector URL Claude uses is https://lm-mcp.website.com/mcp, withAuthorization: Bearer <MCP_AUTH_TOKEN>.
Configuration
| Var | Required | Purpose |
|---|---|---|
MCP_AUTH_TOKEN |
yes | Bearer token the MCP client must present. openssl rand -hex 32. |
CRM_BASE_URL |
yes | Base URL of the CRM API (localhost when co-located; private VPC IP when split out). |
CRM_SERVICE_TOKEN |
yes* | Service credential sent to the CRM as Authorization: Bearer. |
WEBTOOLS_BASE_URL |
no | Reserved for EXECUTE. |
WEBTOOLS_SERVICE_TOKEN |
no | Reserved for EXECUTE. |
PORT |
no | Default 8930. |
* Required in practice because the CRM API is behind requireAuth. See below.
CRM-side contract (what this server depends on)
This repo assumes two things on the CRM. They are not in this repo — they livein Liquid-Mind-CRM:
A service credential. The CRM routes are gated by
requireAuth(...). TheMCP server sendsCRM_SERVICE_TOKENas a bearer token; the CRM must accept anon-interactive service identity (a long-lived service JWT, or a dedicatedmiddleware branch) with a role that permits the READ routes and the actions route.The Action_Queue endpoint + table. The PROPOSE tools
POST /api/actionswith this envelope:{ "actionType": "bid_change", "clientId": 42, "seriesASIN": "B0...", "platform": "AMG", "rationale": "…", "expectedImpactUsd": 430, "modelBasis": "…", "currentValue": { "…": "…" }, "proposedValue": { "…": "…" }, "proposedBy": "claude" }The endpoint should insert a row in
status = 'proposed'and return the createdrow (including itsactionId). The approve/deny UI and the atomicapproved-only execution gate live on the CRM side. (Ask Claude for theAction_Queuemigration + routes — designed but not yet added.)
Until #2 exists, the READ tools work fully and the PROPOSE tools will return aclear error from the CRM (404) — which is the expected state before the queue isbuilt.
Adding a tool
- Add a
server.registerTool(name, { title, description, inputSchema }, handler)intools/read.tsortools/propose.ts. - The handler should be thin: call
crm.get/post/...and returnok(data)/fail(msg). No business logic here — extend the CRM instead. - Keep the tool list small and parameterized; prefer one flexible tool over manynear-duplicates.
Roadmap
- EXECUTE phase: add
apply_approved_action(actionId)— looks up an APPROVEDAction_Queuerow, routes to the matching WebPages tool viawebToolsClient(or enqueues the existing Redis worker job), writes back the result. Refusesanything not inapprovedstate. - Surface C: wrap the AMS_Automation scripts (e.g.
pnl_sheet_filler.py)behind a job-runner or the existing Redis queue so they become EXECUTE targets. - Per-manager identity: swap the shared bearer token for OAuth if you wantper-account-manager attribution on proposals.