Skip to main content
Glama
Sathishkumar1805

Roborock MCP Server

README.md
# Roborock MCP Server

Remote MCP server that lets Claude (claude.ai, Claude Desktop, Claude mobile) control a
Roborock vacuum via natural language. Runs as a Python serverless function on Vercel.

## Architecture

```
Claude (claude.ai / Desktop / mobile)
        │ HTTPS JSON-RPC
        ▼
api/mcp.py  (FastAPI, single Vercel serverless function)
        ├── /authorize, /token, /.well-known/oauth-*   OAuth shim
        │     claude.ai's connector UI requires OAuth; these endpoints wrap
        │     our one static MCP_AUTH_TOKEN in OAuth shape. The "Client ID"
        │     you paste into Claude's connector settings must equal
        │     MCP_AUTH_TOKEN, so this adds no less security than a plain
        │     bearer check.
        └── /api/mcp   JSON-RPC endpoint
              ├── initialize   – protocol handshake
              ├── tools/list   – returns tool schemas
              └── tools/call   – dispatches to roborock_client.py
                       │
                       ▼
              roborock_client.py
                       │ loads a cached login session (no live login —
                       │ this account requires 2FA email-code, which can't
                       │ run inside a serverless function)
                       ▼
              python-roborock device_manager → MQTT → Roborock cloud → vacuum
```

## Repo layout

```
roborock-mcp/
├── api/
│   └── mcp.py           FastAPI app: JSON-RPC + OAuth shim, single entrypoint
├── roborock_client.py   Wraps python-roborock calls used by the MCP tools
├── test_auth.py         One-off script: log in (email code), list devices, cache session
├── requirements.txt     Runtime deps (kept in sync with pyproject.toml)
├── pyproject.toml       Vercel's Python build reads dependencies from here
├── vercel.json          Routes /authorize, /token, /.well-known/* to api/mcp.py
├── .env.local           Local-only secrets (gitignored, not committed)
└── roborock_session.json  Cached login session (gitignored, not committed)
```

## Tools exposed

| Tool | Description |
|---|---|
| `start_clean` | Start cleaning |
| `pause_clean` | Pause the current cleaning run |
| `dock` | Send the robot back to its charging dock |
| `get_status` | Battery %, cleaning state, error code |
| `get_consumables` | Filter/brush/mop wear times |
| `get_rooms` | List known rooms/segments (id + name) |
| `clean_room` | Clean a specific room by segment id |

## Local setup

```
python -m venv venv
venv\Scripts\activate
pip install -r requirements.txt
```

Create `.env.local`:

```
ROBOROCK_EMAIL=you@example.com
```

(Password login isn't supported for accounts with 2FA enabled — leave `ROBOROCK_PASSWORD`
unset and use the email-code flow.)

## Phase 1 — prove auth works

```
python test_auth.py
```

Logs in (prompts for the emailed code on first run), lists your real devices, and caches
the session to `roborock_session.json` so subsequent runs skip the login prompt.

## Phase 2/3 — run the MCP server locally

```
python -m uvicorn api.mcp:app --reload
```

Test with `curl` or the MCP Inspector against `http://127.0.0.1:8000/api/mcp`.

**Windows note:** both `test_auth.py` and `roborock_client.py` set
`asyncio.WindowsSelectorEventLoopPolicy()` — the default Proactor loop doesn't support
`add_reader`/`add_writer`, which the MQTT session needs.

## Phase 4 — deploy to Vercel

```
vercel login
vercel env add ROBOROCK_EMAIL production
vercel env add ROBOROCK_USER_DATA production   # paste roborock_session.json contents
vercel env add MCP_AUTH_TOKEN production        # your own random secret
vercel --prod
```

`ROBOROCK_USER_DATA` exists because Vercel's filesystem isn't writable/persistent between
invocations — the cached session has to come from an env var instead of the local
`roborock_session.json` file.

## Phase 5 — connect to Claude

**Settings → Connectors → Add custom connector**

- URL: `https://roborock-mcp.vercel.app/api/mcp`
- OAuth Client ID: your `MCP_AUTH_TOKEN` value
- Client Secret: leave blank

Then enable the connector for a conversation and try "what's my vacuum's status".

## How it works (plain-English walkthrough)

You type "start cleaning" to Claude → Claude has no way to touch your vacuum directly, it
only knows how to call "tools" some server exposes. This project is that server, sitting
between Claude and Roborock's cloud.

**1. Logging in.** Roborock's cloud needs proof you own the account before it'll let
anyone control your vacuum. Logging in is just a web request: send email + password (or
email + one-time code), get back a session token — like a wristband at a concert, show it
once and you're in for a while without re-checking ID. We cache that token
(`roborock_session.json`) so we're not forced to log in fresh on every single request
(especially since this account requires an emailed code, not just a password).

**2. Finding your devices.** Another web request, logged in this time, says "list my
home's devices" and gets back your vacuum's name, model, and unique ID.

**3. Talking to the vacuum is not a normal web request.** Normal websites are
request-then-response. Vacuums use **MQTT** instead — think of it as a radio channel.
Your vacuum is tuned to a channel; our server tunes into the same one. To send a command,
we broadcast a message on that channel and the vacuum (listening) acts on it. The vacuum
also constantly broadcasts its own status ("battery 87%", "currently cleaning") on that
same channel, which is how we read status back — not an instant reply, but "tune in, ask,
wait for the broadcast."

Why not plain HTTP? HTTP can't have the vacuum speak up unprompted — you'd have to keep
re-asking "any updates?" MQTT lets the device push updates on its own.

**4. python-roborock (the library).** Someone already wrote all the login/MQTT/decoding
logic as a reusable package — we didn't reinvent it, just call its functions:
`client.pass_login(...)` logs in, `create_device_manager(...)` finds devices and opens the
MQTT connection, `device.v1_properties.command.send(RoborockCommand.APP_START)` sends
"start cleaning" over the radio, `status.refresh()` asks for status and waits for the
broadcast reply.

**5. Our server.** `roborock_client.py` is a thin wrapper — it names the specific actions
Claude is allowed to trigger (start, pause, dock, status, consumables, rooms, clean a
room) and calls into python-roborock for each. `api/mcp.py` is the actual web server:
when Claude wants to use a tool, it sends a request in **JSON-RPC** format (a standard
shape for "call this function with these arguments, give me the result"), e.g.:

```json
{"jsonrpc": "2.0", "id": 5, "method": "tools/call",
 "params": {"name": "start_clean", "arguments": {}}}
```

Our server reads `method`/`params.name`, runs the matching function, and replies with the
same `id` so Claude can match the reply to its request:

```json
{"jsonrpc": "2.0", "id": 5,
 "result": {"content": [{"type": "text", "text": "{'result': 'cleaning started'}"}]}}
```

**6. Putting it on the internet (Vercel).** Your own computer isn't always on and has no
public address Claude can reach. Vercel hosts the code for you at a permanent URL
(`https://roborock-mcp.vercel.app`), spinning it up briefly per request rather than
running an always-on machine you'd have to manage.

**7. The login problem on Vercel.** Vercel's environment doesn't keep files between
requests, so the cached "wristband" file doesn't survive there. Its contents get copied
into an environment variable (`ROBOROCK_USER_DATA`) instead — a permanent setting Vercel
keeps around for the app to read, rather than a file on disk.

**8. Securing the server.** The URL is public, so anyone who finds it could send commands
to your vacuum unless we gate it. `MCP_AUTH_TOKEN` is a random secret every request must
include, or it's rejected as unauthorized.

**9. The OAuth complication.** Claude's own connector setup screen insists on an OAuth
flow (the "Sign in with Google"-style dance) and won't accept a plain secret directly.
Real OAuth: app redirects you to the provider's login page, you type your password *there*
(the app never sees it), provider redirects back with a temporary code, app exchanges that
code for an access token behind the scenes. We built a minimal fake version of just the
code-exchange steps — no real login screen, since there's only one user (you) and no
separate identity to protect. If the "Client ID" you type into Claude's connector settings
matches `MCP_AUTH_TOKEN`, we auto-approve and hand back that same token as the "access
token." This satisfies Claude's UI requirement without weakening security below a plain
bearer check.

## Known limitations

- **No auto re-login.** If the cached Roborock session expires, `get_status` etc. will
  start failing with an auth error. Fix: rerun `test_auth.py` locally (email-code login),
  then update the `ROBOROCK_USER_DATA` env var on Vercel and redeploy.
- **`clean_room` is untested** — `get_rooms` returned an empty list on this account
  (map may not be fully synced yet). The command path is implemented per the confirmed
  `RoborockCommand.APP_SEGMENT_CLEAN` API, but hasn't been exercised against a real room id.