Rentvine MCP
# Rentvine MCP (Rentor fork)
MCP server for [Rentvine](https://rentvine.com) — gives Claude (and any MCP client such as Voice Agents) live access to your property management data.
Rentvine stopped maintaining their MCP Server on Apr 25, 2026 leaving Property Management Companies to fork it and continue maintaing it themselves.
> **Fork notice.** This is Rentor's fork of the upstream [`rentvine-mcp`](https://www.npmjs.com/package/rentvine-mcp) npm package (v1.3.2, MIT, by Base Homes). Upstream is **deprecated on npm and its GitHub repo has been deleted**, so this repo is now the maintained line. TypeScript sources here were reconstructed from the published `dist/` — the original package shipped compiled JS only.
>
> Do not `npm install rentvine-mcp` — that pulls the dead upstream. Build from this repo instead.
## Available Tools
| Tool | Description |
|---|---|
| `list_properties` | All properties with address, type, and active status |
| `list_units` | Units for a named property with vacancy and rent |
| `list_leases` | All leases with tenant, rent, dates, and status |
| `list_applications` | Rental applications with applicant and status |
| `list_inspections` | Maintenance inspections with date and inspector |
| `list_work_orders` | Work orders with status and priority |
| `create_work_order` | Create a new maintenance work order |
| `update_work_order` | Update status, priority, cost, or scheduling on a work order |
| `list_tenants` | All tenants with contact details and status; `search` / `active_only` filters. Withholds PII unless `include_sensitive=true` |
| `get_tenant_balance` | Ledger balance for a named tenant |
| `list_owners` | All property owners |
| `list_vendors` | All vendors with full contact, insurance, billing, and audit fields (45 fields) |
| `get_vendor` | Single vendor's full detail record — includes `code` field, website, name components, QuickBooks linkage |
| `vendors_near` | Vendors within N miles of a property, sorted by distance (ZIP-centroid approximation) |
| `list_portfolios` | All portfolios |
| `list_bills` | All bills |
| `create_bill` | Create a new bill |
| `search_transactions` | Search accounting transactions by date, amount, or keyword |
| `list_accounts` | Chart of accounts |
| `list_object_types` | Rentvine object type IDs (for file attachment) |
| `upload_file` | Upload a file and attach it to a property, unit, lease, or work order |
| `list_attachments` | Files attached to any Rentvine object (by object ID + type) |
| `list_work_order_attachments` | Images and files attached to a specific work order |
| `get_file` | Metadata for a single file (name, size, mime type) — no download |
| `download_file` | Download a file as base64 (images, PDFs, up to ~375 KB) |
---
## Install
This is a hosted service, not a local tool. Deploy it once on a Linux server
under **pm2**, front it with **nginx** over HTTPS, and point every client at
that URL. Claude, ChatGPT, voice agents, and teammates all connect to the same
endpoint with a bearer token.
The server holds your Rentvine API credentials. Clients hold only the endpoint
URL and `MCP_AUTH_TOKEN` — no keys are distributed to laptops.
Run **one process per Rentvine account**, each on its own port:
| Environment | Public (nginx) | Internal (node) |
|---|---|---|
| prod | `https://your-host.example.com:8003/mcp` | `127.0.0.1:18003` |
| dev | `https://your-host.example.com:8004/mcp` | `127.0.0.1:18004` |
**Prerequisites:** Ubuntu (or similar), Node.js 18+, nginx, and a TLS
certificate for your host (certbot is fine). Plus your Rentvine API credentials
from **Settings → Users, Roles & API**.
### 1. Get the code onto the server
**Option A — servers with no git/npm access (zip upload).** Build a
self-contained zip on your laptop, upload it, unzip it. No build step, no
`npm install`, and no network needed on the server:
```bash
# on your laptop, in the repo
npm run package
# -> build/rentvine-mcp-<version>-<sha>.zip (~6 MB)
```
The zip contains `dist/`, `node_modules/` (production deps only),
`package.json`, `start-mcp.sh.example`, and a `DEPLOY.txt` with these same
steps. Upload it however you like (`scp`, or any file host + `curl -LOJ`), then:
```bash
sudo mkdir -p /opt/rentvine-mcp
sudo chown -R "$USER":"$USER" /opt/rentvine-mcp
unzip -o rentvine-mcp-*.zip -d /tmp
cp -r /tmp/rentvine-mcp/. /opt/rentvine-mcp/
cd /opt/rentvine-mcp
```
**Re-run `npm run package` after every change** and repeat the upload — the
server has no way to rebuild. The filename carries the version and git SHA
(plus `-dirty` for uncommitted changes) so you can tell what's deployed.
Requires Node.js 18+ on the server. Nothing else.
<details>
<summary><b>Option B</b> — servers with git and npm access (clone and build)</summary>
Install to `/opt/rentvine-mcp`, owned by the user that will run pm2 — **not**
root:
```bash
sudo mkdir -p /opt/rentvine-mcp
sudo chown -R "$USER":"$USER" /opt/rentvine-mcp
git clone https://github.com/Rentor-CA/Rentvine-MCP.git /opt/rentvine-mcp
cd /opt/rentvine-mcp
npm install # do NOT set NODE_ENV=production here
npm run build
```
`/opt` is root-owned by default, so cloning into it as a normal user fails with
`Permission denied` — hence the `chown`. Don't work around that with
`sudo git clone`: everything ends up root-owned, and then `git pull`,
`npm install`, and `npm run build` all need sudo too, while pm2 runs as your
login user and can't write its own build output. npm also behaves badly as root.
Keep the directory owned by the same user that runs `npx pm2` — `pm2 startup`
installs a systemd unit bound to one specific user, and `~/.pm2` lives in that
user's home.
> **Do not set `NODE_ENV=production` for the install.** npm skips
> devDependencies, TypeScript never installs, and `npm run build` dies with
> `tsc: not found`. Install normally, build, then set `NODE_ENV` when you run
> the server. To slim the install afterwards: `npm prune --omit=dev`.
Use the **HTTPS** clone URL — the repo is public, so it needs no credentials.
The SSH form (`git@github.com:…`) requires a key on the machine regardless of
repo visibility, so save it for boxes where you intend to push.
This runs from the clone and installs nothing globally, so it won't disturb an
existing `rentvine-mcp` (the legacy upstream package) already on the box — run
both on different ports while you migrate, then retire the old one.
</details>
Either way, the legacy `rentvine-mcp` already on the box is untouched — give the
new instance its own port (e.g. `18005` internal / `8005` public) and run both
until you cut over.
### 2. Create the launcher
```bash
cp start-mcp.sh.example start-mcp.sh
chmod +x start-mcp.sh
$EDITOR start-mcp.sh # fill in keys, subdomains, and MCP_AUTH_TOKEN
```
Generate a token per environment with `openssl rand -hex 32`. `start-mcp.sh` is
gitignored — it holds live credentials and must never be committed.
The script resolves `dist/http.js` relative to itself, validates that every
credential is set, and `exec`s node so pm2 supervises the server directly
instead of a wrapper shell.
### 3. Start under pm2
```bash
npx pm2 start ./start-mcp.sh --name rentvine-prod -- prod
npx pm2 start ./start-mcp.sh --name rentvine-dev -- dev
npx pm2 save # persist the process list
npx pm2 startup # prints a command to run — restarts pm2 on boot
```
The `-- prod` after the script name is what gets passed *to the script* as `$1`,
selecting the credential block. Everything before `--` is a pm2 flag.
`pm2 save` plus `pm2 startup` are what make this survive a reboot. `save` writes
the current process list to `~/.pm2/dump.pm2`; `startup` prints a `sudo` command
you must actually run to install the systemd unit. **Run both, or the processes
are gone after a restart.** Re-run `pm2 save` any time you add or rename a
process.
#### pm2 command reference
**Inspect**
```bash
npx pm2 list # status table: name, pid, uptime, restarts, cpu, memory
npx pm2 describe rentvine-dev # full details for one process: script path, args, log paths, env
npx pm2 monit # live dashboard (cpu/mem/logs); q to quit
npx pm2 jlist # same as list but JSON — for scripting/monitoring
npx pm2 prettylist # JSON, human-formatted
```
`describe` is the one to reach for when a process behaves unexpectedly — it
shows the resolved script path, the `-- prod`/`-- dev` argument it started with,
its restart count, and where its logs live.
**Logs**
```bash
npx pm2 logs # tail all processes, interleaved
npx pm2 logs rentvine-prod # tail one
npx pm2 logs rentvine-prod --lines 200 # last 200 lines then follow
npx pm2 logs --err # stderr only — startup failures land here
npx pm2 flush # truncate all log files
```
Logs are written to `~/.pm2/logs/<name>-out.log` and `<name>-error.log`. They
are not rotated by default; install `pm2-logrotate` to avoid filling the disk:
```bash
npx pm2 install pm2-logrotate
```
**Lifecycle**
```bash
npx pm2 restart rentvine-prod # restart one
npx pm2 restart all # restart everything
npx pm2 stop rentvine-dev # stop but keep it in the list
npx pm2 start rentvine-dev # start a stopped process by name
npx pm2 delete rentvine-dev # remove from pm2 entirely (then pm2 save)
```
`restart` fully replaces the process, so it re-reads `start-mcp.sh` and picks up
credential changes. Editing the script alone changes nothing until you restart.
`pm2 reload` (zero-downtime) does **not** help here — it is for clustered Node
apps, and these are forked shell scripts.
**Persistence**
```bash
npx pm2 save # snapshot current process list
npx pm2 resurrect # restore from the snapshot
npx pm2 startup # print the boot-persistence install command
npx pm2 unstartup # undo it
```
**Upgrading to a new version**
```bash
cd /opt/rentvine-mcp
git pull
npm install
npm run build
npx pm2 restart all
npx pm2 logs --lines 20 # confirm both came back clean
```
### 4. Expose it over HTTPS
The Node server listens on loopback only — reachable from the server itself, not
from the internet. nginx holds the public TLS port and forwards inward. One
public port per environment:
```
internet ──▶ your-host:8003 (nginx, TLS) ──▶ 127.0.0.1:18003 (node) prod
internet ──▶ your-host:8004 (nginx, TLS) ──▶ 127.0.0.1:18004 (node) dev
public loopback, unreachable
from outside
```
Certificates and TLS termination live in nginx; Node never faces raw internet
traffic. The endpoint is still fully public — the proxy is what makes it so.
**Responses are Server-Sent Events, so proxy buffering must be off.** With
default buffering the connection appears to hang and clients time out. The four
lines that matter are `proxy_http_version 1.1`, `proxy_set_header Connection ""`,
`proxy_buffering off`, and a long `proxy_read_timeout`.
<details>
<summary>nginx — <code>/etc/nginx/sites-available/rentvine-mcp</code></summary>
```nginx
# ---- prod : https://your-host.example.com:8003/mcp ----
server {
listen YOUR.SERVER.IP:8003 ssl;
server_name your-host.example.com;
ssl_certificate /etc/letsencrypt/live/your-host.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-host.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
access_log /var/log/nginx/mcp-prod.access.log;
error_log /var/log/nginx/mcp-prod.error.log;
location / {
proxy_pass http://127.0.0.1:18003;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";
# SSE: buffering off, or streamed responses stall.
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
# ---- dev : https://your-host.example.com:8004/mcp ----
# Same block with 8004 -> 127.0.0.1:18004 and its own log files.
```
Enable and reload:
```bash
sudo ln -s /etc/nginx/sites-available/rentvine-mcp /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
`location /` proxies everything, so `/health` and `/mcp` both pass through.
`listen` is pinned to a specific IP here; plain `listen 8003 ssl;` binds all
interfaces. Open the ports if a firewall is active: `sudo ufw allow 8003/tcp`.
</details>
> **nginx does not authenticate anything here.** It forwards every request
> straight through, so `MCP_AUTH_TOKEN` in the Node process is the only access
> control on the endpoint. See the warning below.
### 5. Verify
```bash
HOST=https://your-host.example.com:8003
INIT='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}'
# 1. Health — public by design, returns only {"ok":true}
curl -sS $HOST/health
# 2. No token — MUST be 401
curl -sS -o /dev/null -w '%{http_code}\n' -X POST $HOST/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -d "$INIT"
# 3. With token — expect the initialize result
curl -sS -X POST $HOST/mcp \
-H "Authorization: Bearer $MCP_AUTH_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -d "$INIT"
# event: message
# data: {"result":{...,"serverInfo":{"name":"rentvine",...}},...}
```
**If step 2 returns anything other than 401, stop and fix it** — your Rentvine
account is exposed to the internet. See the warning below.
> ### ⚠️ `MCP_AUTH_TOKEN` is mandatory here
>
> The server refuses to start without a token **only when binding to a
> non-loopback address**. This deployment binds to `127.0.0.1`, which is
> exempt from that check — so an empty `MCP_AUTH_TOKEN` starts happily and the
> reverse proxy then publishes an unauthenticated `/mcp` to the world. The
> built-in guard cannot save you behind a proxy.
>
> Anyone reaching that endpoint gets full read/write access to your Rentvine
> account: tenant PII, ledgers, and the ability to create work orders and bills.
> `start-mcp.sh.example` refuses to start on an empty or placeholder token for
> this reason — keep that check.
>
> `/health` is intentionally unauthenticated and returns only `{"ok":true}`.
### 6. Connect clients
Every client uses the same two values — the URL and the bearer token. No
Rentvine credentials ever leave the server.
| | |
|---|---|
| **URL** | `https://your-host.example.com:8003/mcp` |
| **Transport** | Streamable HTTP |
| **Auth** | `Authorization: Bearer <MCP_AUTH_TOKEN>` |
**Read and write endpoints.** Besides `/mcp` (every tool), the server serves
the same token on two narrower paths:
| Path | Tools |
|---|---|
| `/mcp/read` | Everything that only reads — nothing changes in Rentvine |
| `/mcp/write` | Only `create_work_order`, `update_work_order`, `create_bill`, `upload_file` |
A client that should never change data connects to `/mcp/read` only; an agent
platform can connect to both and require a person's approval for every call on
`/mcp/write`. Both are **stateless**: each request gets its own server holding
only that path's tools, so there are no sessions to lose on a restart and any
replica can answer. `/mcp` keeps in-memory sessions as before; an unknown or
ended session there now gets `404` (the MCP spec's cue to start a new session).
**`upload_file`'s `file_path`** reads a file from the *server's* disk. Over
stdio that's your machine; over HTTP it's the server, so a caller could upload
any file the server can read — its own credentials included. `/mcp` keeps it as
before (set `RENTVINE_ALLOW_FILE_PATH=0` to turn it off — recommended once no
client relies on it); `/mcp/read` and `/mcp/write` never offer it (send
`file_content_base64` + `file_name`).
**Who a call is for.** On `/mcp/read` and `/mcp/write` an agent platform can
send `x-rentor-user` (the person's email) and `x-rentor-bot` (the bot). Every
tool call — on any endpoint — writes one JSON line to stdout:
`{"event":"tool_call","endpoint":"/mcp/write","tool":"create_work_order","user":"…","bot":"…","ok":true,"ms":412}`
— never the arguments or the result. The headers are attribution, not access
control: any client holding the token could set them.
**Claude Code / Claude Desktop / Cursor / Windsurf / VS Code**
```json
{
"mcpServers": {
"rentvine": {
"type": "http",
"url": "https://your-host.example.com:8003/mcp",
"headers": { "Authorization": "Bearer your_mcp_auth_token" }
}
}
}
```
Config locations — **Claude Code**: `~/.claude.json` or project `.mcp.json` ·
**Cursor**: Settings → MCP → Add new server · **Windsurf**:
`~/.codeium/windsurf/mcp_config.json` · **VS Code (Copilot)**: `.vscode/mcp.json`.
Restart the client; you should see `rentvine` with all 25 tools.
**Vapi / voice agents**
Add as a custom MCP tool provider with the URL above and an `Authorization`
header of `Bearer <MCP_AUTH_TOKEN>`. Voice agents call tools with no human
reviewing arguments first, so give them a token you can rotate independently
and consider a read-only Rentvine API key — `create_work_order`, `create_bill`,
`update_work_order`, and `upload_file` all write to live data.
**ChatGPT (Business / Enterprise / Edu)**
An admin enables **Workspace Settings → Permissions & Roles → Developer Mode**,
then: **Settings → Connectors → Advanced → Add custom MCP server**, URL as
above, Auth `Bearer` → the `MCP_AUTH_TOKEN`. Pick **Developer mode** from the
Plus menu in a new chat and select the `rentvine` connector. Not available on
Plus or Free.
**Anything else**
Any MCP client supporting Streamable HTTP works — point it at the URL with the
bearer header.
> **Sessions apply to `/mcp` only.** They live in memory, keyed by the
> `mcp-session-id` header, so a `pm2 restart` ends them. The server then answers
> **404**, which is the MCP spec's cue for a client to start a new session — but
> not every client acts on it. One agent platform kept replaying the dead ID and
> lost every Rentvine tool until it was itself restarted. `/mcp/read` and
> `/mcp/write` are stateless, so there is nothing to lose on a restart; prefer
> them for agent platforms. Likewise, running more than one replica behind the
> proxy needs sticky routing on that header for `/mcp` — the stateless endpoints
> need none.
---
## Environment Variables
| Variable | Description |
|---|---|
| `RENTVINE_API_KEY` | Your Rentvine API key |
| `RENTVINE_API_SECRET` | Your Rentvine API secret |
| `RENTVINE_COMPANY` | Your subdomain (e.g. `acme` for `acme.rentvine.com`) |
| `MCP_AUTH_TOKEN` | Bearer token clients must present on `/mcp`. Generate with `openssl rand -hex 32`. **Always set this.** The server only *enforces* it at startup when `HOST` is non-loopback — behind nginx that check never fires, so an empty value publishes an open endpoint. |
| `PORT` | HTTP server port (default: `3000`). Use `18003` / `18004` per the table above. |
| `HOST` | Bind address (default: `0.0.0.0`). Set `127.0.0.1` so only nginx can reach it. |
| `RENTVINE_ALLOW_FILE_PATH` | `0` turns off `upload_file`'s `file_path` on `/mcp` (reads the server's own disk). Default: on, as before. `/mcp/read` and `/mcp/write` never offer it; stdio always does. |
All of these are set in `start-mcp.sh`, one block per environment.
---
## Docker (optional — not how we deploy this)
> **This is not our deployment path.** Rentor runs this under **pm2 behind
> nginx**, as described in [Install](#install) — that is the supported setup, the
> one every other section assumes, and the one to use. The `Dockerfile` is an
> unsupported convenience for running the server somewhere else. Nothing in our
> deployment builds it or depends on it, and you never need Docker to install,
> run, or develop this server.
```bash
docker build -t rentvine-mcp .
docker run --rm --init -p 127.0.0.1:18009:3000 \
-e RENTVINE_API_KEY -e RENTVINE_API_SECRET -e RENTVINE_COMPANY -e MCP_AUTH_TOKEN \
rentvine-mcp
```
The image runs `dist/http.js` as the unprivileged `node` user on `0.0.0.0:3000`
(so `MCP_AUTH_TOKEN` is required — it refuses to start without one) with a
`/health` check. Put TLS in front of it as for pm2.
Nothing is pinned inside the image: `PORT` and `HOST` are defaults, so
`-e PORT=18009` moves the listener and the healthcheck follows it. `EXPOSE 3000`
is only metadata — publish whatever you want with `-p`.
Use `--init` as shown. The server registers no SIGTERM handler, so as PID 1 it
ignores the signal and `docker stop` waits the full grace period before killing
it (10s, exit 137); `--init` supplies a real init and it stops in 1s.
---
## Testing
```bash
npm test # scripts/test-endpoints.sh
```
35 checks over auth, the three endpoints, which tools each exposes, session
handling, ID validation, and the call log. It builds if needed, starts its own
server on port 18099 with fake credentials, and stops it again — so it needs no
`.env`, reaches no Rentvine account, and touches no live data. Run it before
opening a PR.
For a quick look at a *running* server against a real account, use
[`scripts/mcp-test.sh`](scripts/mcp-test.sh) (handshake plus one tool call) or
[`scripts/test.sh`](scripts/test.sh) (the same through the MCP Inspector CLI).
Both read credentials from `.env`.
### Continuous integration
[`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs on every pull
request and on pushes to `main`: typecheck, build, the 35 endpoint tests, and a
scan for credential-shaped literals in tracked files. It needs no secrets — the
test suite supplies its own fake ones.
**A red run does not block merging on its own.** GitHub reports the failure and
still offers the merge button until `test` is a *required status check*. Set it
once, after the first run has appeared:
> **Settings → Branches → Add branch ruleset** (or *Add rule* on older repos)
> → target `main` → tick **Require status checks to pass before merging** →
> search `test` and select it → also tick **Require a pull request before
> merging** so the check cannot be bypassed by pushing straight to `main`.
The check only appears in that search once it has run at least once, so open the
CI PR first, then add the rule.
After install, ask Claude things like:
```
List all my properties.
How many units are vacant across all properties?
Which leases expire in the next 60 days?
Show me all open work orders sorted by priority.
What is the balance for tenant [name]?
Create a work order for the leaking roof at 123 Main St, high priority.
Upload this invoice and attach it to work order #1042.
Show me all unpaid bills.
Which vendors have liability insurance expiring in the next 60 days?
Find vendors within 25 miles of property [ID].
Show me all photos attached to work order [ID].
Download the inspection report file [ID].
```
---
## Development
SSH clone here, since contributors push:
```bash
git clone git@github.com:Rentor-CA/Rentvine-MCP.git
cd Rentvine-MCP
npm install
npm run build # tsc → dist/
npm run typecheck # tsc --noEmit, no output
```
Deploy by pushing, then on the server: `git pull && npm install && npm run build
&& npx pm2 restart all`.
### Testing locally
Nobody *uses* this locally — it's a hosted endpoint. But you should always
verify changes locally before deploying.
**1. Credentials.** Point at a **dev** Rentvine account, never production:
`create_work_order`, `create_bill`, `update_work_order`, and `upload_file` write
to live data with no sandbox.
```bash
cp .env.example .env
$EDITOR .env # dev key, secret, subdomain
set -a && source .env && set +a
```
`.env` is gitignored. There's no dotenv dependency — `set -a` exports
everything in the file into your shell, which is all the server needs.
**2. Run it**, exactly as production does (loopback, bearer token, port 18009 so
it can't collide with a deployed instance):
```bash
npm run build
node dist/http.js
# rentvine-mcp HTTP listening on http://127.0.0.1:18009/mcp
```
For an edit loop, run `npm run dev` (`tsc --watch`) in one shell and restart
`node dist/http.js` after each rebuild.
**3. Drive it with `scripts/mcp-test.sh`.** It reads `.env`, does the handshake,
and calls a tool — one command instead of four:
```bash
./scripts/mcp-test.sh # list all 25 tool names
./scripts/mcp-test.sh list_properties # call a tool
./scripts/mcp-test.sh list_tenants '{"search":"aaron"}' # with arguments
./scripts/mcp-test.sh list_work_orders | jq '.[0]' # pipe to jq
./scripts/mcp-test.sh --raw # full tools/list JSON
```
It fails loudly if the server isn't running or the token doesn't match, which
covers the two things that actually go wrong.
<details>
<summary>Or by hand with curl</summary>
Streamable HTTP is session-based: `initialize` first, then send the returned
`mcp-session-id` header on every later request. A bare `tools/list` returns
`400 Bad Request: no valid session ID`.
`$MCP_AUTH_TOKEN` must be exported in your shell — `set -a && source .env && set +a`.
If it's empty the header becomes `Authorization: Bearer ` and every call returns
`{"error":"unauthorized"}`.
```bash
MCP=http://127.0.0.1:18009/mcp
AUTH="Authorization: Bearer $MCP_AUTH_TOKEN"
JSON="Content-Type: application/json"
SSE="Accept: application/json, text/event-stream"
# health — no auth, no session
curl -sS http://127.0.0.1:18009/health
# 1. handshake, capturing the session id from the response headers
SID=$(curl -sS -D- -o /dev/null -X POST $MCP -H "$AUTH" -H "$JSON" -H "$SSE" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \
| tr -d '\r' | awk -F': ' '/^mcp-session-id:/{print $2}')
echo "session: $SID"
# 2. complete the handshake (returns 202)
curl -sS -X POST $MCP -H "$AUTH" -H "$JSON" -H "$SSE" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. list tools — responses are SSE, so strip the "data: " prefix for jq
curl -sS -X POST $MCP -H "$AUTH" -H "$JSON" -H "$SSE" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| sed -n 's/^data: //p' | jq -r '.result.tools[].name'
# 4. call one
curl -sS -X POST $MCP -H "$AUTH" -H "$JSON" -H "$SSE" -H "mcp-session-id: $SID" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_properties","arguments":{}}}' \
| sed -n 's/^data: //p' | jq -r '.result.content[0].text'
```
Step 3 should print 25 tool names. Step 4 returns the tool's JSON payload, or a
`Rentvine 4xx …` string if the credentials are wrong — which still proves the
transport, routing, and projection path all work.
</details>
**4. Or use the official MCP Inspector.** Standard tooling, no repo checkout
needed — works against localhost or the deployed endpoint:
```bash
export TOKEN=your_mcp_auth_token
URL=http://127.0.0.1:18009/mcp # or https://your-host.example.com:8003/mcp
# list tools
npx @modelcontextprotocol/inspector --cli $URL \
--header "Authorization: Bearer $TOKEN" --method tools/list
# call one, with arguments
npx @modelcontextprotocol/inspector --cli $URL \
--header "Authorization: Bearer $TOKEN" \
--method tools/call --tool-name list_tenants --tool-arg page_size=100
# read the API-docs resource
npx @modelcontextprotocol/inspector --cli $URL \
--header "Authorization: Bearer $TOKEN" \
--method resources/read --uri rentvine://api-docs
```
It also has `--tui` (interactive terminal browser) and `--web` (browser UI, good
for clicking through tools and inspecting raw JSON-RPC). The transport is
auto-detected from the URL — `/mcp` means Streamable HTTP.
`scripts/mcp-test.sh` stays useful for scripted checks: no npx download, and it
reads `.env` so there's no token to pass.
**5. Or point a real client at it.** Same config as production, just localhost:
```json
{
"mcpServers": {
"rentvine-local": {
"type": "http",
"url": "http://127.0.0.1:18009/mcp",
"headers": { "Authorization": "Bearer dev-token" }
}
}
}
```
This is the fastest way to test tool *descriptions* — whether the model picks
the right tool and fills arguments correctly is not something curl can tell you.
**6. Quick checks without a server.** `dist/index.js` is the stdio transport;
it speaks JSON-RPC on stdin/stdout with no session handshake, which makes it
handy for scripted assertions:
```bash
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
| node dist/index.js | tail -1 | jq '.result.tools | length'
```
Run `npm run typecheck` before pushing.
### Layout
```
src/
index.ts stdio entrypoint
http.ts Streamable-HTTP entrypoint (Express, in-memory sessions)
createServer.ts tool registry — 24 registerTool() calls + zod schemas
tools.ts projections: Rentvine camelCase → our snake_case contract
client.ts HTTP layer: Basic auth, envelope unwrapping, timeouts
apiDocs.ts Rentvine API reference, served as rentvine://api-docs
types/ ambient .d.ts for untyped deps (zipcodes)
```
### Adding a tool
Three edits, in order:
1. **`src/client.ts`** — add a `fetchX()` that calls the endpoint and returns the raw rows.
2. **`src/tools.ts`** — add an exported function projecting raw → snake_case.
3. **`src/createServer.ts`** — `server.registerTool("x", { description, inputSchema }, handler)`.
Endpoints documented in `src/apiDocs.ts` but not yet wrapped include the eleven
`/accounting/diagnostics/*` routes, `/maintenance/vendor-trades`,
`/leases/{leaseID}`, `/properties/units/export`, and
`/accounting/transactions/entries/search`.
### Known issues
- **Work-order enum maps are unverified.** `WO_STATUS` / `WO_PRIORITY` / `LEASE_STATUS` in `src/tools.ts` disagree with the enum tables in `src/apiDocs.ts` (which document status as 1=Pending, 2=Open, 3=Closed, 4=On Hold and priority as 1–3 only, with no `emergency`). If the docs are right, `update_work_order(status: "cancelled")` sets the work order to *On Hold*. Verify against `GET /maintenance/work-order/statuses` before trusting either.
- **No pagination on list tools.** `list_properties`, `list_leases`, `list_work_orders`, `list_bills`, `list_vendors`, and `list_accounts` send no `page`/`pageSize`, and Rentvine defaults to 15–25 rows. They will silently truncate as the portfolio grows. Only `search_transactions` exposes paging.
- **`unwrap()` returns `[]` on unrecognized shapes** (`src/client.ts`), so an API contract change reads as "no results" rather than an error.
- **Fuzzy lookups take first match.** `list_units` and `get_tenant_balance` substring-match names and silently pick the first hit.
- **`list_tenants` paging is unverified.** `page`/`page_size` are passed through to `GET /tenants`, but that endpoint's paging behavior is unconfirmed. If Rentvine ignores them, results are silently capped at its default page size. `search` and `active_only` are applied **client-side, after** the fetch, so they filter only what came back on that page.
### Handling tenant PII
Rentvine serves tenants, vendors, and owners from one shared contact schema, so
every tenant record carries `birthDate`, `identificationNumber`,
`identificationTypeID`, and `achAccountNumberTruncated` — real PII on a consumer.
`list_tenants` therefore splits its projection: identity, contact, status, and
audit fields by default; date of birth, government ID, tax/payee, and
payout/ACH fields only when `include_sensitive=true`. Without that split, a bare
"list the tenants" request would put every tenant's DOB and bank details into
the model's context window.
If you'd rather have the full record by default, drop the `include_sensitive`
branch at the end of `listTenants()` in `src/tools.ts` and always spread
`projectTenantSensitive(c)`.
## Troubleshooting
**`{"error":"unauthorized"}` from `/mcp`** — the client's bearer token doesn't
match `MCP_AUTH_TOKEN`. This is the server's own 401, not Rentvine's.
**`Rentvine 401 Unauthorized` inside a tool result** — wrong `RENTVINE_API_KEY`,
`RENTVINE_API_SECRET`, or `RENTVINE_COMPANY`. Verify in Rentvine → Settings →
Users, Roles & API.
**Client connects but hangs on the first call** — nginx is buffering. Confirm
`proxy_buffering off`, `proxy_http_version 1.1`, and `proxy_set_header Connection ""`
are in the `location` block; responses are SSE and stall without them.
**502 Bad Gateway** — node isn't running or is on a different port than
`proxy_pass`. Check with `npx pm2 list` and:
```bash
sudo ss -tlnp | grep -E ':(8003|8004|18003|18004)'
npx pm2 logs rentvine-prod --lines 50
```
**Server won't start** — `npx pm2 logs <name>`. `FATAL: MCP_AUTH_TOKEN must be
set…` means `HOST` isn't loopback and no token is set. `FATAL: <VAR> is unset or
still CHANGE_ME` is the launcher's own check. `tsc: not found` during build means
`NODE_ENV=production` was set during `npm install`.
**Gone after a reboot** — `npx pm2 save` and `npx pm2 startup` were never run.
**Confirm the token is actually set in the live process** (prints `1` or `0`,
never the secret):
```bash
sudo tr '\0' '\n' < /proc/$(pgrep -f 'dist/http.js' | head -1)/environ \
| grep -c '^MCP_AUTH_TOKEN=.\+'
```
**Empty results** — your Rentvine account may have no data in that category, the
list endpoint truncated at Rentvine's default page size, or the API returned an
unexpected envelope (see `unwrap()` under Known issues).
TDQS
Scored across 25 tools
Most tools map cleanly to distinct resources and actions. Minor overlap exists between list_attachments and list_work_order_attachments, and get_file vs download_file could be confused, but descriptions clarify intent.
Mostly follows list_/create_/update_ conventions, but breaks the pattern with get_tenant_balance, get_vendor, search_transactions, and vendors_near. A consistent verb prefix would improve predictability.
25 tools cover many property-management entities, but the count is heavy and includes near-duplicates like list_attachments/list_work_order_attachments and get_file/download_file.
Read coverage is broad across owners, tenants, vendors, bills, leases, and work orders, but write operations are limited to work orders and bills; obvious gaps like creating/updating properties, leases, or tenants remain.