lofty-mcp
by nbhav
README.md
# lofty-mcp
An MCP server that gives Claude tools to work with a [Lofty](https://www.lofty.com) CRM
account — leads, tasks/appointments, calendar, communications, and notes — by calling
Lofty's REST API directly.
For the reasoning behind how this project is built (why REST instead of `lofty-cli`,
the auth-scheme quirk, the HTTP-mode security tradeoffs), see [`DESIGN.md`](DESIGN.md).
This document is about what the project does and how to run it.
## Concepts
- **MCP (Model Context Protocol)** is the protocol Claude uses to discover and call
tools exposed by an external server. This project *is* one such server: it doesn't
talk to Claude directly, it registers **tools** (e.g. `lofty_leads_list`,
`lofty_notes_create`) that a client (Claude Code, Cowork, etc.) can call on your
behalf.
- **One tool module per Lofty resource.** `src/lofty_mcp/tools/` has one file per
resource — `leads.py`, `tasks.py`, `calendar.py`, `communication.py`, `notes.py`,
`whoami.py` — each registering the MCP tools for that resource's REST endpoints.
35 tools total today; see `specs/rest/index.json` for what's implemented vs. pending.
- **Tool annotations.** Every tool carries
[MCP `ToolAnnotations`](https://modelcontextprotocol.io)
(`readOnlyHint`/`destructiveHint`/`idempotentHint`) — reads are marked read-only,
creates/sends are non-idempotent, updates/deletes are destructive — so a well-behaved
client can decide what to auto-approve vs. confirm with you. These are hints, not
enforcement: nothing in the server itself blocks a destructive call.
- **Two transports, two use cases:**
- **stdio** (default) — Claude Code spawns the server as a local subprocess per
session. This is how you'd normally use it day to day.
- **Streamable HTTP** — the server runs as a long-lived process bound to a port,
for remote clients (e.g. Cowork's "Add custom connector") that can't spawn a
process on your machine. Needs a real HTTPS URL in front of it; see
"Connecting to a remote client" below.
- **Auth is a single API key**, not OAuth. `LOFTY_API_KEY` is a personal-access token
from the Lofty CRM, sent as `Authorization: token <key>` on every request. There's no
refresh flow — if it stops working, you regenerate it in the CRM.
## Initial setup
You need:
1. **Docker.** Everything — build, run, test — happens inside containers; nothing from
this project's Python dependency stack is installed on your host. Install
[Docker Desktop](https://www.docker.com/products/docker-desktop/) (macOS/Windows) or
Docker Engine (Linux), and confirm it's running with `docker info`.
2. **A Lofty API key.** In the Lofty CRM, go to **Settings → Integrations → API** and
generate a personal-access key.
3. **`jq`**, used by several `Makefile` targets to edit JSON config in place:
`brew install jq` (macOS) or your distro's package manager.
4. *(Only if you'll use HTTP mode / remote connectors)* **`cloudflared`**:
`brew install cloudflare/cloudflare/cloudflared`.
Then create a `.env` file at the repo root (already in `.gitignore` — never commit it):
```
LOFTY_API_KEY=your-key-here
```
If the key stops working, tool calls fail with a clear `LoftyApiError` pointing back at
Settings → Integrations → API — there's no refresh path, so regenerating the key is
always the fix.
## Project layout
```
src/lofty_mcp/
server.py entrypoint: MCPServer, lifespan-injected httpx client, transport selection
rest_client.py HTTP client: base URL, auth header, error mapping
tools/ one module per resource (leads, tasks, calendar, communication, notes, whoami)
specs/
openapi.json Lofty's full OpenAPI spec (source of truth)
rest/*.json generated per-resource summaries (see specs/README.md)
scripts/
generate_rest_specs.py regenerates specs/rest/*.json from specs/openapi.json
mcp_test_harness.py reliable stdio request/response harness for smoke-testing
Dockerfile plain python:3.12-slim, no CLI binary needed
Makefile build/run/register/teardown commands (see below)
DESIGN.md why the project is built this way (not how to run it)
```
## Building and testing
```
make help # list all commands
make build # docker build the image
make test # run scripts/mcp_test_harness.py against it (real API calls)
```
## Connecting to Claude Code
```
make up # builds the image, registers it in .mcp.json
```
This writes a `lofty` entry into the project's `.mcp.json`
(`command: docker, args: [run, -i, --rm, --env-file, <path>, lofty-mcp:py]`). Claude
Code auto-detects project-scoped `.mcp.json` files — restart the session (or run `/mcp`)
and approve the new server when prompted. `make down` removes the entry.
Each session spawns a fresh `docker run -i --rm` process over stdio; Docker tears it
down automatically when the session ends, so there's no separate container lifecycle to
manage here.
## Connecting to a remote client (e.g. Cowork)
Remote connectors need a real HTTPS URL, since they run in the cloud and can't spawn
processes on your machine. There are two ways to get one, depending on how long-lived
and trusted the deployment needs to be.
### Quick tunnel — short-lived local testing
```
make http-up # runs the server in HTTP mode + a cloudflared quick tunnel
make http-down # tears both down
```
`make http-up` binds the server to `0.0.0.0:8000` and fronts it with `cloudflared
tunnel --url` — no Cloudflare account needed, but the URL is random and changes every
run. By default this has **no authentication beyond `LOFTY_API_KEY`** baked into the
container: anyone with the tunnel URL can call every tool, including sends and deletes.
To close that gap for a quick tunnel session, set a shared secret before starting it:
```
MCP_HTTP_AUTH_TOKEN=some-long-random-string make http-up
```
With `MCP_HTTP_AUTH_TOKEN` set, every request must include
`Authorization: Bearer <that token>` or the server returns 401 before the request
reaches any tool. Without it, the server prints a loud warning on startup and runs
unauthenticated — fine for a few minutes of local testing torn down right after, not
for anything left running.
### Persistent tunnel — fixed hostname, Cloudflare Access OAuth
For anything longer-lived, use a **named tunnel** at a hostname you control, and put
[Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/policies/access/)
in front of it for real OAuth login (Google, GitHub, Okta, etc.) — unauthenticated
requests never reach your machine at all.
One-time setup, requires a domain added to your Cloudflare account:
```
make tunnel-login # opens a browser, authorizes cloudflared against your account (once per machine)
```
Add to `.env`:
```
TUNNEL_HOSTNAME=lofty-mcp.yourdomain.com
TUNNEL_NAME=lofty-mcp # optional, defaults to lofty-mcp
```
Then:
```
make tunnel-create # creates (or reuses) the named tunnel, routes TUNNEL_HOSTNAME's DNS to it
```
In the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/) → Access →
Applications, add a Self-hosted application for `TUNNEL_HOSTNAME`, pick an identity
provider under Settings → Authentication, and add a policy allowing the people who
should have access. Then:
```
make http-up-persistent # runs the server + the named tunnel at TUNNEL_HOSTNAME
make http-down-persistent # tears both down
make http-status-persistent # check what's running
```
Once Access is configured, `MCP_HTTP_AUTH_TOKEN` (above) is still worth setting as
defense-in-depth at the application layer, but Access is what actually stops
unauthenticated traffic from reaching the container.
## Adding a new tool
Every resource in `specs/rest/*.json` not yet wired up (see `specs/rest/index.json`'s
`resources` list for what's implemented vs. pending) follows the same pattern as the
existing ones:
1. Read the resource's spec file for exact paths/params (or `specs/openapi.json`
directly for full request/response schemas).
2. Add `src/lofty_mcp/tools/<resource>.py` with a `register(mcp)` function, one
`@mcp.tool(annotations=ToolAnnotations(...))`-decorated function per operation,
calling `rest_client.request()`.
3. Register it in `src/lofty_mcp/tools/__init__.py`.
4. `make test` to confirm it works against the real API.
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues