Skip to main content
Glama
3xian

douyin-dm-mcp

by 3xian
README.md
# douyin-dm-mcp

A Model Context Protocol server and local HTTP API for Douyin web direct messages, built with Playwright. Both interfaces reuse the same persistent local browser profile, read currently rendered conversations and messages, and send individual messages only when explicitly enabled.

The project uses Douyin's current standalone chat page:

```text
https://www.douyin.com/chat?isPopup=1
```

Login and account status checks still use the Douyin home page. `/messages` currently returns a 404 page and is not used for automation.

## Safety boundaries

- `DOUYIN_ALLOW_SEND` defaults to `false`, so real sending is disabled by default.
- `send_message` defaults to `dryRun: true`. Dry runs validate the current snapshot without opening a conversation or changing page state.
- A real send requires both dry-run to be disabled and `DOUYIN_ALLOW_SEND=true`.
- Before reading or a real send, the server verifies that the nickname is unique, the conversation position and exact nickname still match, and the open chat title matches.
- Duplicate nicknames are marked `targetable: false` and are refused by both MCP tools and the nickname-based CLI.
- If the result cannot be confirmed after clicking send, the server returns `SEND_STATUS_UNKNOWN` and does not retry automatically.
- Each browser profile has an exclusive filesystem lock to prevent concurrent Chromium instances from corrupting it. MCP, the HTTP API, and the operator CLI cannot run at the same time against the same `DOUYIN_PROFILE`.
- All page operations are serialized to prevent cross-conversation reads or sends.
- The project does not modify browser fingerprints, bypass verification challenges, or call Douyin's private WebSocket/Protobuf interfaces.
- Logs are written to stderr and redact message bodies, cookies, and password fields.

## Current limitations

Douyin's rendered conversation DOM does not expose a supported stable conversation ID, user ID, `sec_uid`, or stable profile link. Therefore:

- `conversationKey` is opaque and valid only for the latest `list_conversations` snapshot.
- Calling `list_conversations` creates new keys and immediately expires every key from the previous snapshot.
- Every conversation returns `stableKey: false`; duplicate nicknames additionally return `targetable: false`.
- Call `list_conversations` before calling `read_messages` or `send_message`, then use a key from that exact result.
- The conversation list contains only items currently rendered by the browser; `complete` is always `false`.
- Fuzzy nickname matching, bulk sending, stranger search, and search-to-send fallbacks are intentionally unsupported.

Detailed live-page evidence is recorded in [`RESEARCH.md`](RESEARCH.md).

## Requirements

- Node.js 20 or newer
- npm
- A desktop environment capable of displaying Chromium for the initial QR-code login

## Installation

```bash
npm install
npx playwright install chromium
npm run build
```

## Configuration

| Environment variable | Default | Description |
| --- | --- | --- |
| `DOUYIN_PROFILE` | `default` | Profile name; letters, numbers, underscores, and hyphens only |
| `DOUYIN_HEADLESS` | `false` | Run Chromium headlessly; keep this `false` for initial login |
| `DOUYIN_ALLOW_SEND` | `false` | Allow real message sends |
| `DOUYIN_DEBUG` | `false` | Enable debug logging |
| `DOUYIN_NAVIGATION_TIMEOUT_MS` | `60000` | Navigation timeout in milliseconds |
| `DOUYIN_ACTION_TIMEOUT_MS` | `10000` | Page action timeout in milliseconds |
| `DOUYIN_MIN_SEND_INTERVAL_MS` | `3000` | Minimum interval between send attempts |
| `DOUYIN_API_HOST` | `127.0.0.1` | HTTP API bind address |
| `DOUYIN_API_PORT` | `3000` | HTTP API port |
| `DOUYIN_API_KEY` | unset | Bearer key, minimum 16 characters; required for non-loopback binding |

These variables are read from the process environment. The project does not load `.env`. Use `.env.example` as a reference, then export the values in your shell or set them in the MCP client `env` block.

Browser data is stored in:

```text
.data/profiles/<DOUYIN_PROFILE>
```

This directory contains authentication data. Do not commit or share it.

## Login

For first use or an expired session, run:

```bash
npm run login
```

Scan the displayed QR code with Douyin. After login, the script prints structured status, closes Chromium safely, and keeps the authenticated session in the persistent profile.

Check the current session:

```bash
npm run status
```

Example successful result:

```json
{
  "ok": true,
  "browserRunning": true,
  "loggedIn": true,
  "currentUrl": "https://www.douyin.com/jingxuan"
}
```

## Starting the MCP server

The compiled entry point is:

```bash
node dist/index.js
```

Codex CLI example:

```bash
codex mcp add douyin-dm -- node /absolute/path/to/douyin-dm-mcp/dist/index.js
```

Generic MCP client configuration:

```json
{
  "mcpServers": {
    "douyin-dm": {
      "command": "node",
      "args": ["/absolute/path/to/douyin-dm-mcp/dist/index.js"],
      "env": {
        "DOUYIN_PROFILE": "default",
        "DOUYIN_ALLOW_SEND": "false"
      }
    }
  }
}
```

For an authorized real send, set `DOUYIN_ALLOW_SEND` to `true` for that MCP process and restart it. Do not leave sending globally enabled.

Do not start this process while the HTTP API or CLI already holds the same profile lock.

## Starting the HTTP API

Run from source:

```bash
npm run api
```

Or run the compiled entry point:

```bash
node dist/api.js
```

Do not start this process while MCP or the CLI already holds the same profile lock.

The default base URL is `http://127.0.0.1:3000`. The unauthenticated health check is:

```bash
curl http://127.0.0.1:3000/health
```

API routes:

| Method | Path | Input | Purpose |
| --- | --- | --- | --- |
| `GET` | `/health` | None | Process liveness; no auth, no browser |
| `GET` | `/api/v1/status` | None | Login / browser session |
| `GET` | `/api/v1/conversations?limit=20` | Query parameter `limit`, 1–100 | Current rendered snapshot + new keys |
| `POST` | `/api/v1/messages/read` | JSON `{ "conversationKey": "...", "limit": 20 }` | Visible messages for a snapshot key |
| `POST` | `/api/v1/messages/send` | JSON `{ "conversationKey": "...", "text": "...", "dryRun": true }` | Dry-run by default; real send needs both gates |

POST requests require `Content-Type: application/json`. Sending remains a dry run by default. A real send still requires both `"dryRun": false` and `DOUYIN_ALLOW_SEND=true`.

Example:

```bash
curl "http://127.0.0.1:3000/api/v1/conversations?limit=20"

curl -X POST http://127.0.0.1:3000/api/v1/messages/read \
  -H "Content-Type: application/json" \
  -d '{"conversationKey":"fallback:...:0","limit":20}'
```

Loopback access does not require an API key. Binding to any other host is refused unless `DOUYIN_API_KEY` is set to at least 16 characters. When configured, send it on every `/api/v1/*` request:

```bash
curl http://127.0.0.1:3000/api/v1/status \
  -H "Authorization: Bearer YOUR_API_KEY"
```

The API returns the same structured success and Douyin error objects as MCP. Request parsing errors use `INVALID_REQUEST`, `INVALID_JSON`, `UNSUPPORTED_MEDIA_TYPE`, or `PAYLOAD_TOO_LARGE`; authentication failures use `UNAUTHORIZED`.

## MCP tools

### `browser_status`

Checks whether the persistent Douyin browser profile is authenticated.

Input: none.

### `list_conversations`

Opens the standalone chat page and returns currently rendered conversations with opaque `conversationKey` values for the new snapshot.

```json
{
  "limit": 20
}
```

Conversation fields:

- `conversationKey`
- `stableKey`, currently always `false`
- `position`
- `nickname`
- `preview`
- `timestamp`
- `targetable`, `false` when duplicate nicknames make safe selection impossible

### `read_messages`

Reads currently visible messages from a conversation returned by `list_conversations`.

```json
{
  "conversationKey": "fallback:550e8400-e29b-41d4-a716-446655440000:0",
  "limit": 20
}
```

Message fields:

- `direction`: `incoming` or `outgoing`, from verified sender-side DOM evidence
- `type`: `text`, or `unsupported` for unrecognized message types
- `content`: visible text, or `null` when empty

Conversations with `targetable: false` are refused.

### `send_message`

Sends one message to a verified conversation.

```json
{
  "conversationKey": "fallback:550e8400-e29b-41d4-a716-446655440000:0",
  "text": "Test message",
  "dryRun": true
}
```

A real send requires all of the following:

1. `DOUYIN_ALLOW_SEND=true`.
2. `dryRun=false`.
3. The target nickname is unique in the current snapshot.
4. The conversation position and exact nickname still match the snapshot.
5. The open chat title exactly matches the target nickname.
6. The message has no leading or trailing whitespace.
7. The logical Slate editor text exactly matches the requested text.

After clicking send, the server waits for a new outgoing message with the exact canonical text. If confirmation fails, it returns `SEND_STATUS_UNKNOWN`; callers must inspect the conversation manually instead of retrying automatically. The minimum send interval is retained across conversation-list refreshes.

## Operator CLI

List currently rendered conversations:

```bash
npm run chat -- list
```

Read messages by an exact, unique nickname:

```bash
npm run chat -- read "Exact nickname"
```

Real sends also require `DOUYIN_ALLOW_SEND`. PowerShell example:

```powershell
$env:DOUYIN_ALLOW_SEND="true"
npm run chat -- send "Exact nickname" "Test message"
Remove-Item Env:DOUYIN_ALLOW_SEND
```

The CLI accepts exact nicknames only and refuses to continue when no match or multiple matches are found.

Do not run the CLI while MCP or the HTTP API already holds the same profile lock.

## Development

```bash
npm run lint
npm test
npm run build
npm run smoke:mcp
```

Tests cover configuration parsing, structured errors, profile locking, page-operation serialization, snapshot expiry, duplicate refusal, target verification, message direction, dry-run isolation, composer rollback, successful send confirmation, unknown send status, persistent rate limiting, and package-safe defaults.

## Project structure

```text
src/
  browser/          Browser lifecycle, profile locking, and operation serialization
  douyin/           DouyinService, centralized selectors, and page objects
  index.ts          MCP stdio server
  api.ts            HTTP API process entry point
  api/              Versioned HTTP routes, validation, and authentication
scripts/
  login.ts          QR-code login
  status.ts         Authentication status check
  chat.ts           Operator CLI
  mcp-smoke.ts      MCP transport smoke check
tests/unit/         Repeatable behavioral tests
RESEARCH.md         Live-page evidence and engineering research
```

## License

Licensed under the permissive [MIT License](LICENSE).