Skip to main content
Glama
ChaosChild

AppFlowy MCP Server

by ChaosChild
README.md
# AppFlowy MCP Server

[![CI](https://github.com/ChaosChild/appflowy-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/ChaosChild/appflowy-mcp/actions/workflows/ci.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)

A [Model Context Protocol](https://modelcontextprotocol.io) server that gives AI assistants full read/write access to [AppFlowy Cloud](https://appflowy.com): workspaces, folders, pages, databases, trash, and favourites. It also converts Markdown into real AppFlowy document blocks, so an agent can write a properly formatted page instead of dumping a wall of plain text.

One file, three dependencies, no build step.

**Why this exists:** AppFlowy Cloud issues short-lived JWTs. Pasting a fresh token into your MCP config every hour is miserable, so this server logs in and refreshes tokens on its own and caches the session at `~/.appflowy_mcp_token.json`. You configure credentials once and forget about auth.

---

## Quick start

```bash
git clone https://github.com/ChaosChild/appflowy-mcp.git
cd appflowy-mcp
pip install -r requirements.txt
cp .env.example .env      # then fill in your credentials
python appflowy_mcp.py    # starts the stdio server; Ctrl+C to stop
```

The server speaks JSON-RPC over stdin/stdout, so running it directly just waits silently for a client. That silence means it started correctly. Wire it into a client below.

### Claude Code

```bash
claude mcp add appflowy \
  --env APPFLOWY_BASE_URL=https://beta.appflowy.cloud \
  --env APPFLOWY_EMAIL=you@example.com \
  --env APPFLOWY_PASSWORD=your-password \
  -- python -u /absolute/path/to/appflowy_mcp.py
```

### Claude Desktop

Config file: `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS).

```json
{
  "mcpServers": {
    "appflowy": {
      "command": "python",
      "args": ["-u", "/absolute/path/to/appflowy_mcp.py"],
      "env": {
        "APPFLOWY_BASE_URL": "https://beta.appflowy.cloud",
        "APPFLOWY_EMAIL": "you@example.com",
        "APPFLOWY_PASSWORD": "your-password"
      }
    }
  }
}
```

<details>
<summary><b>Other clients</b> (Antigravity, Hermes, OpenCode)</summary>

**Antigravity** (`~/.gemini/config/mcp_config.json`, or `.agents/mcp_config.json` per workspace) uses the same `mcpServers` shape as Claude Desktop above.

**Hermes Agent** (`~/.hermes/config.yaml`):

```yaml
mcp_servers:
  appflowy:
    command: "python"
    args: ["-u", "/absolute/path/to/appflowy_mcp.py"]
    env:
      APPFLOWY_BASE_URL: "https://beta.appflowy.cloud"
      APPFLOWY_EMAIL: "you@example.com"
      APPFLOWY_PASSWORD: "your-password"
```

**OpenCode** (`~/.config/opencode/opencode.jsonc`). Note it uses `environment`, not `env`:

```jsonc
{
  "mcp": {
    "appflowy": {
      "type": "local",
      "command": ["python", "-u", "/absolute/path/to/appflowy_mcp.py"],
      "enabled": true,
      "environment": {
        "APPFLOWY_BASE_URL": "https://beta.appflowy.cloud",
        "APPFLOWY_EMAIL": "you@example.com",
        "APPFLOWY_PASSWORD": "your-password"
      }
    }
  }
}
```

</details>

Any MCP client that can launch a stdio process works. Use an absolute path to `appflowy_mcp.py`, and point `command` at the interpreter of the environment where you installed the requirements.

---

## Authentication

Set `APPFLOWY_BASE_URL` plus **one** of the three options. Credentials can live in `.env` next to the script or in the client's `env` block; the client's environment wins.

| Option | Variables | Trade-off |
|---|---|---|
| **A. Email + password** *(recommended)* | `APPFLOWY_EMAIL`, `APPFLOWY_PASSWORD` | Zero maintenance. Password sits in a config file. |
| **B. Refresh token** | `APPFLOWY_REFRESH_TOKEN` | Zero maintenance, no password on disk. Call `get_auth_token` once with a password grant to mint one. |
| **C. Access token** | `APPFLOWY_ACCESS_TOKEN` | No stored secret beyond a short-lived JWT, but you re-paste it roughly hourly. |

On every call the server walks this chain and stops at the first thing that works: cached token, `APPFLOWY_ACCESS_TOKEN`, cached refresh token, `APPFLOWY_REFRESH_TOKEN`, then email/password login. A token expiring within 60 seconds counts as expired, and any `401` triggers one automatic re-auth and retry. Delete `~/.appflowy_mcp_token.json` to force a clean login.

Self-hosted AppFlowy works: set `APPFLOWY_BASE_URL` to your instance. Every tool also takes optional `base_url` and `access_token` arguments to override per call.

---

## Tools

Twenty-two tools. Every one returns the parsed JSON response, or `{"error": ..., "details": ...}` on failure, so an agent can read the error rather than crash on it.

**Auth**
| Tool | Purpose |
|---|---|
| `get_auth_token` | Mint an access + refresh token pair via password or refresh grant. |

**Workspaces and folders**
| Tool | Purpose |
|---|---|
| `get_workspace_list` | List all workspaces for the authenticated user. |
| `get_workspace_folder` | Walk the page/folder tree. Takes `depth` and `root_view_id`. |

**Databases**
| Tool | Purpose |
|---|---|
| `get_databases` | List databases in a workspace. |
| `get_database_fields` | Field (column) definitions, including field IDs. |
| `get_database_row_ids` | All row UUIDs in a database. |
| `get_database_rows_detail` | Cell values for specific rows, optionally with row documents. |
| `get_updated_database_row_ids` | Rows changed after an ISO 8601 timestamp. |
| `create_database_row` | Add a row from a `cells` map, plus optional Markdown body. |
| `upsert_database_row` | Update or insert a row, keyed by `pre_hash`. |

**Pages**
| Tool | Purpose |
|---|---|
| `create_page` | Create a document, grid, board, or calendar page. |
| `get_page` | Fetch a page's metadata and content. |
| `update_page` | Rename, set an icon, lock or unlock. Only sends fields you pass. |
| `move_page_to_trash` | Soft delete. |

**Markdown documents**
| Tool | Purpose |
|---|---|
| `create_document_from_markdown` | New page from Markdown, converted to real blocks. |
| `append_markdown_to_page` | Append blocks to an existing page, keeping its `view_id`. |
| `replace_page_document` | Replace a page body. See the caveat below. |

**Trash and favourites**
| Tool | Purpose |
|---|---|
| `get_trash` | List trashed pages. |
| `restore_page_from_trash` | Restore a trashed page. |
| `delete_page_permanently` | Irreversible delete. |
| `get_favorite_pages` | List favourites. |
| `toggle_favorite_page` | Add or remove a favourite. |

---

## Markdown conversion

`create_document_from_markdown` and `append_markdown_to_page` run Markdown through a small parser that emits AppFlowy's block tree, so headings are real headings and checkboxes are real checkboxes.

| Markdown | AppFlowy block |
|---|---|
| `# ...` through `###### ...` | `heading` (levels 1 to 6) |
| Paragraph text (wrapped lines join) | `paragraph` |
| `- item`, `* item`, `+ item` | `bulleted_list` |
| `1. item` | `numbered_list` |
| `- [ ] item`, `- [x] item` | `todo_list` with `checked` |
| `> quote` (consecutive lines merge) | `quote` |
| `---`, `***`, `___` | `divider` |
| ` ```lang ` fenced block | `code` with `language` |
| `**bold**`, `*italic*`, `` `code` ``, `[text](url)` | inline delta attributes |
| Pipe tables | `code` block, formatting preserved |

Known limits, by design:

- **Tables** render as a code block. AppFlowy's table blocks are a nested structure this parser does not generate; a monospaced table still reads fine.
- **Nested lists** flatten to one level. Indentation is not tracked.
- **Images** are supported through `create_page`'s raw `page_data`, not through Markdown `![]()` syntax.
- If your AppFlowy build rejects `code` or `quote` blocks with `InvalidBlock`, set `FALLBACK_CODE_AS_PARAGRAPH` or `FALLBACK_QUOTE_AS_PARAGRAPH` to `True` near the top of the conversion section to degrade them to paragraphs.

### The `replace_page_document` caveat

AppFlowy Cloud has no in-place "set body" endpoint. The only true whole-document replace is the CRDT `full-sync` route, which needs Yjs encoding in Python and is out of scope here. So `replace_page_document` **recreates** the page: it reads the original's parent, name, and icon, creates a new page under the same parent, then trashes the original.

Consequences you should know about before calling it:

- The page gets a **new `view_id`**. Existing links to it break.
- It lands at the **end** of its parent section, so ordering may need a manual fix.
- Page history does not carry over.
- If creation fails, the original is left untouched.

Prefer `append_markdown_to_page` when you only need to add content. It uses `/append-block`, keeps the `view_id`, and preserves history.

---

## Using it with an AI agent

[`skills/appflowy/SKILL.md`](skills/appflowy/SKILL.md) is a ready-made agent skill covering the workflows that are easy to get wrong: resolving IDs before acting, choosing between append and replace, writing database cells, and handling trash safely.

For Claude Code, install it with:

```bash
mkdir -p ~/.claude/skills
cp -r skills/appflowy ~/.claude/skills/
```

For other agents, the file is plain Markdown. Paste it into your system prompt, `AGENTS.md`, or the equivalent.

---

## Development

```bash
pip install -r requirements.txt pytest
python -m pytest
```

The suite is fully offline: no network, no AppFlowy account, and it redirects the token cache to a temp file so your real session is never touched. It covers JWT validation and the auth fallback chain, HTTP error and retry handling, the Markdown parser, and each tool's request shaping.

CI runs the same suite on Python 3.10 through 3.13.

Pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).

---

## Security notes

- **Never commit `.env`.** It is gitignored, along with anything matching `.env.*` except the example.
- `~/.appflowy_mcp_token.json` holds a live access and refresh token in plaintext, with whatever permissions your umask gives it. Treat it as a credential.
- MCP servers run with your full account access. This one can permanently delete pages via `delete_page_permanently`. Review what your agent proposes before approving destructive calls.
- Option B or C avoids storing a reusable password in a config file that agents and backup tools can read.

## License

MIT. See [LICENSE](LICENSE).