affine-mcp-server 1.4.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -28
- package/dist/auth.js +31 -7
- package/dist/cli.js +288 -0
- package/dist/config.js +103 -7
- package/dist/graphqlClient.js +86 -15
- package/dist/index.js +34 -4
- package/dist/markdown/index.js +3 -0
- package/dist/markdown/parse.js +465 -0
- package/dist/markdown/render.js +202 -0
- package/dist/markdown/types.js +1 -0
- package/dist/tools/blobStorage.js +3 -3
- package/dist/tools/docs.js +2375 -181
- package/dist/tools/workspaces.js +5 -4
- package/dist/ws.js +7 -2
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted or cloud). It exposes AFFiNE workspaces and documents to AI assistants over stdio.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/dawncr0w/affine-mcp-server/releases)
|
|
6
6
|
[](https://github.com/modelcontextprotocol/typescript-sdk)
|
|
7
7
|
[](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml)
|
|
8
8
|
[](LICENSE)
|
|
@@ -16,15 +16,16 @@ A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted
|
|
|
16
16
|
- Purpose: Manage AFFiNE workspaces and documents through MCP
|
|
17
17
|
- Transport: stdio only (Claude Desktop / Codex compatible)
|
|
18
18
|
- Auth: Token, Cookie, or Email/Password (priority order)
|
|
19
|
-
- Tools:
|
|
19
|
+
- Tools: 43 focused tools with WebSocket-based document editing
|
|
20
20
|
- Status: Active
|
|
21
21
|
|
|
22
|
-
> New in v1.
|
|
22
|
+
> New in v1.6.0: Added tag workflows, markdown import/export/replace workflows, and direct database editing tools (`add_database_column`, `add_database_row`) with end-to-end validation coverage.
|
|
23
23
|
|
|
24
24
|
## Features
|
|
25
25
|
|
|
26
26
|
- Workspace: create (with initial doc), read, update, delete
|
|
27
|
-
- Documents: list/get/read/publish/revoke + create/append
|
|
27
|
+
- Documents: list/get/read/publish/revoke + create/append/replace/delete + markdown import/export + tags (WebSocket‑based)
|
|
28
|
+
- Database workflows: create database blocks, then add columns/rows via MCP tools
|
|
28
29
|
- Comments: full CRUD and resolve
|
|
29
30
|
- Version History: list
|
|
30
31
|
- Users & Tokens: current user, sign in, profile/settings, and personal access tokens
|
|
@@ -53,7 +54,51 @@ Note: From v1.2.2+ the CLI wrapper (`bin/affine-mcp`) ensures Node runs the ESM
|
|
|
53
54
|
|
|
54
55
|
## Configuration
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
### Interactive login (recommended)
|
|
58
|
+
|
|
59
|
+
The easiest way to configure credentials:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
npm i -g affine-mcp-server
|
|
63
|
+
affine-mcp login
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
This stores credentials in `~/.config/affine-mcp/config` (mode 600). The MCP server reads them automatically — no environment variables needed.
|
|
67
|
+
|
|
68
|
+
**AFFiNE Cloud** (`app.affine.pro`): you'll be prompted to paste an API token from Settings → Integrations → MCP Server.
|
|
69
|
+
|
|
70
|
+
**Self-hosted instances**: you can choose between email/password (recommended — auto-generates an API token) or pasting a token manually.
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
$ affine-mcp login
|
|
74
|
+
Affine MCP Server — Login
|
|
75
|
+
|
|
76
|
+
Affine URL [https://app.affine.pro]: https://my-affine.example.com
|
|
77
|
+
|
|
78
|
+
Auth method — [1] Email/password (recommended) [2] Paste API token: 1
|
|
79
|
+
Email: user@example.com
|
|
80
|
+
Password: ****
|
|
81
|
+
Signing in...
|
|
82
|
+
✓ Signed in as: User Name <user@example.com>
|
|
83
|
+
|
|
84
|
+
Generating API token...
|
|
85
|
+
✓ Created token: ut_abc123... (name: affine-mcp-2026-02-18)
|
|
86
|
+
|
|
87
|
+
Detecting workspaces...
|
|
88
|
+
Found 1 workspace: abc-def-123 (by User Name, 1 member, 2/10/2026)
|
|
89
|
+
Auto-selected.
|
|
90
|
+
|
|
91
|
+
✓ Saved to /home/user/.config/affine-mcp/config (mode 600)
|
|
92
|
+
The MCP server will use these credentials automatically.
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Other CLI commands:
|
|
96
|
+
- `affine-mcp status` — show current config and test connection
|
|
97
|
+
- `affine-mcp logout` — remove stored credentials
|
|
98
|
+
|
|
99
|
+
### Environment variables
|
|
100
|
+
|
|
101
|
+
You can also configure via environment variables (they override the config file):
|
|
57
102
|
|
|
58
103
|
- Required: `AFFINE_BASE_URL`
|
|
59
104
|
- Auth (choose one): `AFFINE_API_TOKEN` | `AFFINE_COOKIE` | `AFFINE_EMAIL` + `AFFINE_PASSWORD`
|
|
@@ -62,8 +107,42 @@ Configure via environment variables (shell or app config). `.env` files are no l
|
|
|
62
107
|
Authentication priority:
|
|
63
108
|
1) `AFFINE_API_TOKEN` → 2) `AFFINE_COOKIE` → 3) `AFFINE_EMAIL` + `AFFINE_PASSWORD`
|
|
64
109
|
|
|
110
|
+
> **Cloudflare note**: `AFFINE_EMAIL`/`AFFINE_PASSWORD` auth requires programmatic access to `/api/auth/sign-in`. AFFiNE Cloud (`app.affine.pro`) is behind Cloudflare, which blocks these requests. Use `AFFINE_API_TOKEN` for cloud, or use `affine-mcp login` which handles this automatically. Email/password works for self-hosted instances without Cloudflare.
|
|
111
|
+
|
|
65
112
|
## Quick Start
|
|
66
113
|
|
|
114
|
+
### Claude Code
|
|
115
|
+
|
|
116
|
+
After running `affine-mcp login`, add to your project's `.mcp.json`:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{
|
|
120
|
+
"mcpServers": {
|
|
121
|
+
"affine": {
|
|
122
|
+
"command": "affine-mcp"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
No `env` block needed — the server reads `~/.config/affine-mcp/config` automatically.
|
|
129
|
+
|
|
130
|
+
If you prefer explicit env vars instead of the config file:
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"mcpServers": {
|
|
135
|
+
"affine": {
|
|
136
|
+
"command": "affine-mcp",
|
|
137
|
+
"env": {
|
|
138
|
+
"AFFINE_BASE_URL": "https://app.affine.pro",
|
|
139
|
+
"AFFINE_API_TOKEN": "ut_xxx"
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
67
146
|
### Claude Desktop
|
|
68
147
|
|
|
69
148
|
Add to your Claude Desktop configuration:
|
|
@@ -78,7 +157,23 @@ Add to your Claude Desktop configuration:
|
|
|
78
157
|
"affine": {
|
|
79
158
|
"command": "affine-mcp",
|
|
80
159
|
"env": {
|
|
81
|
-
"AFFINE_BASE_URL": "https://
|
|
160
|
+
"AFFINE_BASE_URL": "https://app.affine.pro",
|
|
161
|
+
"AFFINE_API_TOKEN": "ut_xxx"
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Or with email/password for self-hosted instances (not supported on AFFiNE Cloud — see Cloudflare note above):
|
|
169
|
+
|
|
170
|
+
```json
|
|
171
|
+
{
|
|
172
|
+
"mcpServers": {
|
|
173
|
+
"affine": {
|
|
174
|
+
"command": "affine-mcp",
|
|
175
|
+
"env": {
|
|
176
|
+
"AFFINE_BASE_URL": "https://your-self-hosted-affine.com",
|
|
82
177
|
"AFFINE_EMAIL": "you@example.com",
|
|
83
178
|
"AFFINE_PASSWORD": "secret!",
|
|
84
179
|
"AFFINE_LOGIN_AT_START": "async"
|
|
@@ -89,28 +184,21 @@ Add to your Claude Desktop configuration:
|
|
|
89
184
|
```
|
|
90
185
|
|
|
91
186
|
Tips
|
|
92
|
-
- Prefer `
|
|
187
|
+
- Prefer `affine-mcp login` or `AFFINE_API_TOKEN` for zero‑latency startup.
|
|
93
188
|
- If your password contains `!` (zsh history expansion), wrap it in single quotes in shells or use the JSON config above.
|
|
94
189
|
|
|
95
190
|
### Codex CLI
|
|
96
191
|
|
|
97
192
|
Register the MCP server with Codex:
|
|
98
193
|
|
|
99
|
-
-
|
|
100
|
-
- `
|
|
101
|
-
- `codex mcp add affine --env AFFINE_BASE_URL=https://your-affine-instance.com --env 'AFFINE_EMAIL=you@example.com' --env 'AFFINE_PASSWORD=secret!' --env AFFINE_LOGIN_AT_START=async -- affine-mcp`
|
|
102
|
-
|
|
103
|
-
- Use npx (no global install)
|
|
104
|
-
- `codex mcp add affine --env AFFINE_BASE_URL=https://your-affine-instance.com --env 'AFFINE_EMAIL=you@example.com' --env 'AFFINE_PASSWORD=secret!' --env AFFINE_LOGIN_AT_START=async -- npx -y -p affine-mcp-server affine-mcp`
|
|
194
|
+
- With config file (after `affine-mcp login`):
|
|
195
|
+
- `codex mcp add affine -- affine-mcp`
|
|
105
196
|
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
- Cookie: `codex mcp add affine --env AFFINE_BASE_URL=https://... --env "AFFINE_COOKIE=affine_session=...; affine_csrf=..." -- affine-mcp`
|
|
197
|
+
- With API token:
|
|
198
|
+
- `codex mcp add affine --env AFFINE_BASE_URL=https://app.affine.pro --env AFFINE_API_TOKEN=ut_xxx -- affine-mcp`
|
|
109
199
|
|
|
110
|
-
|
|
111
|
-
-
|
|
112
|
-
- Command: `affine-mcp`
|
|
113
|
-
- Environment: `AFFINE_BASE_URL` + one auth method (`AFFINE_API_TOKEN` | `AFFINE_COOKIE` | `AFFINE_EMAIL`/`AFFINE_PASSWORD`)
|
|
200
|
+
- With email/password (self-hosted only):
|
|
201
|
+
- `codex mcp add affine --env AFFINE_BASE_URL=https://your-self-hosted-affine.com --env 'AFFINE_EMAIL=you@example.com' --env 'AFFINE_PASSWORD=secret!' --env AFFINE_LOGIN_AT_START=async -- affine-mcp`
|
|
114
202
|
|
|
115
203
|
### Cursor
|
|
116
204
|
|
|
@@ -124,8 +212,8 @@ Project-local (`.cursor/mcp.json`) example:
|
|
|
124
212
|
"affine": {
|
|
125
213
|
"command": "affine-mcp",
|
|
126
214
|
"env": {
|
|
127
|
-
"AFFINE_BASE_URL": "https://
|
|
128
|
-
"AFFINE_API_TOKEN": "
|
|
215
|
+
"AFFINE_BASE_URL": "https://app.affine.pro",
|
|
216
|
+
"AFFINE_API_TOKEN": "ut_xxx"
|
|
129
217
|
}
|
|
130
218
|
}
|
|
131
219
|
}
|
|
@@ -141,8 +229,8 @@ If you prefer `npx`:
|
|
|
141
229
|
"command": "npx",
|
|
142
230
|
"args": ["-y", "-p", "affine-mcp-server", "affine-mcp"],
|
|
143
231
|
"env": {
|
|
144
|
-
"AFFINE_BASE_URL": "https://
|
|
145
|
-
"AFFINE_API_TOKEN": "
|
|
232
|
+
"AFFINE_BASE_URL": "https://app.affine.pro",
|
|
233
|
+
"AFFINE_API_TOKEN": "ut_xxx"
|
|
146
234
|
}
|
|
147
235
|
}
|
|
148
236
|
}
|
|
@@ -159,14 +247,25 @@ If you prefer `npx`:
|
|
|
159
247
|
- `delete_workspace` – delete workspace permanently
|
|
160
248
|
|
|
161
249
|
### Documents
|
|
162
|
-
- `list_docs` – list documents with pagination
|
|
250
|
+
- `list_docs` – list documents with pagination (includes `node.tags`)
|
|
251
|
+
- `list_tags` – list all tags in a workspace
|
|
252
|
+
- `list_docs_by_tag` – list documents by tag
|
|
163
253
|
- `get_doc` – get document metadata
|
|
164
254
|
- `read_doc` – read document block content and plain text snapshot (WebSocket)
|
|
255
|
+
- `export_doc_markdown` – export document content as markdown
|
|
165
256
|
- `publish_doc` – make document public
|
|
166
257
|
- `revoke_doc` – revoke public access
|
|
167
258
|
- `create_doc` – create a new document (WebSocket)
|
|
259
|
+
- `create_doc_from_markdown` – create a document from markdown content
|
|
260
|
+
- `create_tag` – create a reusable workspace-level tag
|
|
261
|
+
- `add_tag_to_doc` – attach a tag to a document
|
|
262
|
+
- `remove_tag_from_doc` – detach a tag from a document
|
|
168
263
|
- `append_paragraph` – append a paragraph block (WebSocket)
|
|
169
|
-
- `append_block` – append
|
|
264
|
+
- `append_block` – append canonical block types (text/list/code/media/embed/database/edgeless) with strict validation and placement control (`data_view` currently falls back to database)
|
|
265
|
+
- `add_database_column` – add a column to a database block (`rich-text`, `select`, `multi-select`, `number`, `checkbox`, `link`, `date`)
|
|
266
|
+
- `add_database_row` – add a row to a database block with values mapped by column name/ID
|
|
267
|
+
- `append_markdown` – append markdown content to an existing document
|
|
268
|
+
- `replace_doc_with_markdown` – replace the main note content with markdown content
|
|
170
269
|
- `delete_doc` – delete a document (WebSocket)
|
|
171
270
|
|
|
172
271
|
### Comments
|
|
@@ -210,13 +309,16 @@ npm run pack:check
|
|
|
210
309
|
|
|
211
310
|
- `tool-manifest.json` is the source of truth for publicly exposed tool names.
|
|
212
311
|
- CI validates that `registerTool(...)` declarations match the manifest exactly.
|
|
312
|
+
- For full environment verification, run `npm run test:e2e` (Docker + MCP + Playwright).
|
|
313
|
+
- Additional focused runners: `npm run test:db-create`, `npm run test:bearer`, `npm run test:playwright`.
|
|
213
314
|
|
|
214
315
|
## Troubleshooting
|
|
215
316
|
|
|
216
317
|
Authentication
|
|
217
|
-
-
|
|
318
|
+
- **Cloudflare (403 "Just a moment...")**: AFFiNE Cloud (`app.affine.pro`) uses Cloudflare protection, which blocks programmatic sign-in via `/api/auth/sign-in`. Use `AFFINE_API_TOKEN` instead, or run `affine-mcp login` which guides you through the right method automatically. Email/password auth only works for self-hosted instances.
|
|
319
|
+
- Email/Password: only works on self-hosted instances without Cloudflare. Ensure your instance allows password auth and credentials are valid.
|
|
218
320
|
- Cookie: copy cookies (e.g., `affine_session`, `affine_csrf`) from the browser DevTools after login
|
|
219
|
-
- Token: generate a personal access token; verify it hasn't expired
|
|
321
|
+
- Token: generate a personal access token; verify it hasn't expired. Run `affine-mcp status` to test.
|
|
220
322
|
- Startup timeouts: v1.2.2+ includes a CLI wrapper fix and default async login to avoid blocking the MCP handshake. Set `AFFINE_LOGIN_AT_START=sync` only if needed.
|
|
221
323
|
|
|
222
324
|
Connection
|
|
@@ -243,6 +345,18 @@ Workspace visibility
|
|
|
243
345
|
|
|
244
346
|
## Version History
|
|
245
347
|
|
|
348
|
+
### 1.6.0 (2026‑02‑24)
|
|
349
|
+
- Added 11 document workflow tools: tags (`list_tags`, `list_docs_by_tag`, `create_tag`, `add_tag_to_doc`, `remove_tag_from_doc`), markdown roundtrip (`export_doc_markdown`, `create_doc_from_markdown`, `append_markdown`, `replace_doc_with_markdown`), and database operations (`add_database_column`, `add_database_row`)
|
|
350
|
+
- Added interactive CLI commands: `affine-mcp login`, `affine-mcp status`, `affine-mcp logout`
|
|
351
|
+
- Added Docker + Playwright E2E pipeline and CI workflow for auth/database regression checks
|
|
352
|
+
- Tool surface increased from 32 to 43 canonical tools
|
|
353
|
+
- Added release test commands (`test:e2e`, `test:db-create`, `test:bearer`, `test:playwright`) and package dependencies for markdown conversion + Playwright
|
|
354
|
+
|
|
355
|
+
### 1.5.0 (2026‑02‑13)
|
|
356
|
+
- Expanded `append_block` from Step1 to Step4 profiles: canonical text/list/code/divider/callout/latex/table/bookmark/media/embed plus `database`, `data_view`, `surface_ref`, `frame`, `edgeless_text`, `note` (`data_view` currently mapped to database for stability)
|
|
357
|
+
- Added strict field validation and canonical parent enforcement for page/note/surface containers
|
|
358
|
+
- Added local integration runner coverage for all 30 append_block cases against a live AFFINE server
|
|
359
|
+
|
|
246
360
|
### 1.4.0 (2026‑02‑13)
|
|
247
361
|
- Added `read_doc` for reading document block snapshot + plain text
|
|
248
362
|
- Added Cursor setup examples and troubleshooting notes for JSON-RPC method usage
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fetch } from "undici";
|
|
2
|
+
const AUTH_FETCH_TIMEOUT_MS = 30_000;
|
|
2
3
|
function extractCookiePairs(setCookies) {
|
|
3
4
|
const pairs = [];
|
|
4
5
|
for (const sc of setCookies) {
|
|
@@ -8,16 +9,38 @@ function extractCookiePairs(setCookies) {
|
|
|
8
9
|
}
|
|
9
10
|
return pairs.join("; ");
|
|
10
11
|
}
|
|
12
|
+
/** Reject cookie values containing CR/LF to prevent header injection. */
|
|
13
|
+
function assertNoCRLF(value, label) {
|
|
14
|
+
if (/[\r\n]/.test(value)) {
|
|
15
|
+
throw new Error(`${label} contains illegal CR/LF characters`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
11
18
|
export async function loginWithPassword(baseUrl, email, password) {
|
|
12
19
|
const url = `${baseUrl.replace(/\/$/, "")}/api/auth/sign-in`;
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
const timer = setTimeout(() => controller.abort(), AUTH_FETCH_TIMEOUT_MS);
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await fetch(url, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ email, password }),
|
|
28
|
+
signal: controller.signal,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
if (err.name === "AbortError")
|
|
33
|
+
throw new Error(`Sign-in request timed out after ${AUTH_FETCH_TIMEOUT_MS / 1000}s`);
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
}
|
|
18
39
|
if (!res.ok) {
|
|
19
|
-
const
|
|
20
|
-
|
|
40
|
+
const raw = await res.text().catch(() => "");
|
|
41
|
+
const sanitized = raw.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
|
|
42
|
+
const truncated = sanitized.length > 200 ? sanitized.slice(0, 200) + "..." : sanitized;
|
|
43
|
+
throw new Error(`Sign-in failed: ${res.status} ${truncated}`);
|
|
21
44
|
}
|
|
22
45
|
const anyHeaders = res.headers;
|
|
23
46
|
let setCookies = [];
|
|
@@ -33,5 +56,6 @@ export async function loginWithPassword(baseUrl, email, password) {
|
|
|
33
56
|
throw new Error("Sign-in succeeded but no Set-Cookie received");
|
|
34
57
|
}
|
|
35
58
|
const cookieHeader = extractCookiePairs(setCookies);
|
|
59
|
+
assertNoCRLF(cookieHeader, "Cookie header from sign-in");
|
|
36
60
|
return { cookieHeader };
|
|
37
61
|
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { fetch } from "undici";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as readline from "readline";
|
|
4
|
+
import { CONFIG_FILE, loadConfigFile, writeConfigFile, validateBaseUrl, VERSION } from "./config.js";
|
|
5
|
+
import { loginWithPassword } from "./auth.js";
|
|
6
|
+
const CLI_FETCH_TIMEOUT_MS = 30_000;
|
|
7
|
+
class CliError extends Error {
|
|
8
|
+
constructor(message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "CliError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function ask(prompt, hidden = false) {
|
|
14
|
+
if (hidden && process.stdin.isTTY) {
|
|
15
|
+
return readHidden(prompt);
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
const rl = readline.createInterface({
|
|
19
|
+
input: process.stdin,
|
|
20
|
+
output: process.stderr,
|
|
21
|
+
terminal: process.stdin.isTTY ?? false,
|
|
22
|
+
});
|
|
23
|
+
rl.question(prompt, (answer) => {
|
|
24
|
+
rl.close();
|
|
25
|
+
resolve((answer || "").trim());
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Read a line with echo disabled using raw-mode stdin (no private API hacks). */
|
|
30
|
+
function readHidden(prompt) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
process.stderr.write(prompt);
|
|
33
|
+
const buf = [];
|
|
34
|
+
process.stdin.setRawMode(true);
|
|
35
|
+
process.stdin.resume();
|
|
36
|
+
process.stdin.setEncoding("utf8");
|
|
37
|
+
const onData = (ch) => {
|
|
38
|
+
switch (ch) {
|
|
39
|
+
case "\r":
|
|
40
|
+
case "\n":
|
|
41
|
+
cleanup();
|
|
42
|
+
process.stderr.write("\n");
|
|
43
|
+
resolve(buf.join(""));
|
|
44
|
+
break;
|
|
45
|
+
case "\u0003": // Ctrl-C
|
|
46
|
+
cleanup();
|
|
47
|
+
process.stderr.write("\n");
|
|
48
|
+
reject(new CliError("Aborted."));
|
|
49
|
+
break;
|
|
50
|
+
case "\u007F": // Backspace
|
|
51
|
+
case "\b":
|
|
52
|
+
buf.pop();
|
|
53
|
+
break;
|
|
54
|
+
default:
|
|
55
|
+
buf.push(ch);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const cleanup = () => {
|
|
59
|
+
process.stdin.setRawMode(false);
|
|
60
|
+
process.stdin.pause();
|
|
61
|
+
process.stdin.removeListener("data", onData);
|
|
62
|
+
};
|
|
63
|
+
process.stdin.on("data", onData);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
async function gql(baseUrl, auth, query, variables) {
|
|
67
|
+
const headers = {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
"User-Agent": `affine-mcp-server/${VERSION}`,
|
|
70
|
+
};
|
|
71
|
+
if (auth.token)
|
|
72
|
+
headers["Authorization"] = `Bearer ${auth.token}`;
|
|
73
|
+
if (auth.cookie)
|
|
74
|
+
headers["Cookie"] = auth.cookie;
|
|
75
|
+
const body = { query };
|
|
76
|
+
if (variables)
|
|
77
|
+
body.variables = variables;
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const timer = setTimeout(() => controller.abort(), CLI_FETCH_TIMEOUT_MS);
|
|
80
|
+
let res;
|
|
81
|
+
try {
|
|
82
|
+
res = await fetch(`${baseUrl}/graphql`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers,
|
|
85
|
+
body: JSON.stringify(body),
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
if (err.name === "AbortError")
|
|
91
|
+
throw new Error(`Request timed out after ${CLI_FETCH_TIMEOUT_MS / 1000}s`);
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
}
|
|
97
|
+
if (!res.ok)
|
|
98
|
+
throw new Error(`HTTP ${res.status}`);
|
|
99
|
+
const json = await res.json();
|
|
100
|
+
if (json.errors)
|
|
101
|
+
throw new Error(json.errors.map((e) => e.message).join("; "));
|
|
102
|
+
return json.data;
|
|
103
|
+
}
|
|
104
|
+
async function detectWorkspace(baseUrl, auth) {
|
|
105
|
+
console.error("Detecting workspaces...");
|
|
106
|
+
try {
|
|
107
|
+
const data = await gql(baseUrl, auth, `query {
|
|
108
|
+
workspaces {
|
|
109
|
+
id createdAt memberCount
|
|
110
|
+
owner { name }
|
|
111
|
+
}
|
|
112
|
+
}`);
|
|
113
|
+
const workspaces = data.workspaces;
|
|
114
|
+
if (workspaces.length === 0) {
|
|
115
|
+
console.error(" No workspaces found.");
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
const formatWs = (w) => {
|
|
119
|
+
const owner = w.owner?.name || "unknown";
|
|
120
|
+
const members = w.memberCount ?? 0;
|
|
121
|
+
const date = w.createdAt ? new Date(w.createdAt).toLocaleDateString() : "";
|
|
122
|
+
const membersStr = members === 1 ? "1 member" : `${members} members`;
|
|
123
|
+
return `${w.id} (by ${owner}, ${membersStr}, ${date})`;
|
|
124
|
+
};
|
|
125
|
+
if (workspaces.length === 1) {
|
|
126
|
+
console.error(` Found 1 workspace: ${formatWs(workspaces[0])}`);
|
|
127
|
+
console.error(" Auto-selected.");
|
|
128
|
+
return workspaces[0].id;
|
|
129
|
+
}
|
|
130
|
+
console.error(` Found ${workspaces.length} workspaces:`);
|
|
131
|
+
workspaces.forEach((w, i) => console.error(` ${i + 1}) ${formatWs(w)}`));
|
|
132
|
+
const choice = (await ask(`\nSelect [1]: `)) || "1";
|
|
133
|
+
const idx = parseInt(choice, 10) - 1;
|
|
134
|
+
if (idx < 0 || idx >= workspaces.length) {
|
|
135
|
+
throw new CliError("Invalid selection.");
|
|
136
|
+
}
|
|
137
|
+
return workspaces[idx].id;
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (err instanceof CliError)
|
|
141
|
+
throw err;
|
|
142
|
+
console.error(` Could not list workspaces: ${err.message}`);
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async function loginWithEmail(baseUrl) {
|
|
147
|
+
const email = await ask("Email: ");
|
|
148
|
+
const password = await ask("Password: ", true);
|
|
149
|
+
if (!email || !password) {
|
|
150
|
+
throw new CliError("Email and password are required.");
|
|
151
|
+
}
|
|
152
|
+
console.error("Signing in...");
|
|
153
|
+
let cookieHeader;
|
|
154
|
+
try {
|
|
155
|
+
({ cookieHeader } = await loginWithPassword(baseUrl, email, password));
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
throw new CliError(`Sign-in failed: ${err.message}`);
|
|
159
|
+
}
|
|
160
|
+
// Verify identity
|
|
161
|
+
const auth = { cookie: cookieHeader };
|
|
162
|
+
try {
|
|
163
|
+
const data = await gql(baseUrl, auth, "query { currentUser { name email } }");
|
|
164
|
+
console.error(`✓ Signed in as: ${data.currentUser.name} <${data.currentUser.email}>\n`);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
throw new CliError(`Session verification failed: ${err.message}`);
|
|
168
|
+
}
|
|
169
|
+
// Auto-generate an API token so the MCP server can use token auth (no cookie expiry issues)
|
|
170
|
+
console.error("Generating API token...");
|
|
171
|
+
let token;
|
|
172
|
+
try {
|
|
173
|
+
const data = await gql(baseUrl, auth, `mutation($input: GenerateAccessTokenInput!) { generateUserAccessToken(input: $input) { id name token } }`, { input: { name: `affine-mcp-${new Date().toISOString().slice(0, 10)}` } });
|
|
174
|
+
token = data.generateUserAccessToken.token;
|
|
175
|
+
console.error(`✓ Token created (name: ${data.generateUserAccessToken.name})\n`);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
throw new CliError(`Failed to generate token: ${err.message}\n` +
|
|
179
|
+
"You can create one manually in Affine Settings → Integrations → MCP Server");
|
|
180
|
+
}
|
|
181
|
+
const workspaceId = await detectWorkspace(baseUrl, { token });
|
|
182
|
+
return { token, workspaceId };
|
|
183
|
+
}
|
|
184
|
+
async function loginWithToken(baseUrl) {
|
|
185
|
+
console.error("\nTo generate a token:");
|
|
186
|
+
console.error(` 1. Open ${baseUrl}/settings in your browser`);
|
|
187
|
+
console.error(" 2. Account Settings → Integrations → MCP Server");
|
|
188
|
+
console.error(" 3. Copy the Personal access token\n");
|
|
189
|
+
const token = await ask("API token: ", true);
|
|
190
|
+
if (!token) {
|
|
191
|
+
throw new CliError("No token provided.");
|
|
192
|
+
}
|
|
193
|
+
console.error("Testing connection...");
|
|
194
|
+
try {
|
|
195
|
+
const data = await gql(baseUrl, { token }, "query { currentUser { name email } }");
|
|
196
|
+
console.error(`✓ Authenticated as: ${data.currentUser.name} <${data.currentUser.email}>\n`);
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
throw new CliError(`Authentication failed: ${err.message}`);
|
|
200
|
+
}
|
|
201
|
+
const workspaceId = await detectWorkspace(baseUrl, { token });
|
|
202
|
+
return { token, workspaceId };
|
|
203
|
+
}
|
|
204
|
+
async function login() {
|
|
205
|
+
console.error("Affine MCP Server — Login\n");
|
|
206
|
+
const existing = loadConfigFile();
|
|
207
|
+
if (existing.AFFINE_API_TOKEN) {
|
|
208
|
+
console.error(`Existing config: ${CONFIG_FILE}`);
|
|
209
|
+
console.error(` URL: ${existing.AFFINE_BASE_URL || "(default)"}`);
|
|
210
|
+
console.error(` Token: (set)`);
|
|
211
|
+
console.error(` Workspace: ${existing.AFFINE_WORKSPACE_ID || "(none)"}\n`);
|
|
212
|
+
const overwrite = await ask("Overwrite? [y/N] ");
|
|
213
|
+
if (!/^[yY]$/.test(overwrite)) {
|
|
214
|
+
console.error("Keeping existing config.");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
console.error("");
|
|
218
|
+
}
|
|
219
|
+
const defaultUrl = "https://app.affine.pro";
|
|
220
|
+
const rawUrl = (await ask(`Affine URL [${defaultUrl}]: `)) || defaultUrl;
|
|
221
|
+
const baseUrl = validateBaseUrl(rawUrl);
|
|
222
|
+
const isSelfHosted = !baseUrl.includes("affine.pro");
|
|
223
|
+
let result;
|
|
224
|
+
if (isSelfHosted) {
|
|
225
|
+
const method = await ask("\nAuth method — [1] Email/password (recommended) [2] Paste API token: ");
|
|
226
|
+
if (method === "2") {
|
|
227
|
+
result = await loginWithToken(baseUrl);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
result = await loginWithEmail(baseUrl);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
// Cloudflare blocks programmatic sign-in on app.affine.pro — token is the only option
|
|
235
|
+
result = await loginWithToken(baseUrl);
|
|
236
|
+
}
|
|
237
|
+
writeConfigFile({
|
|
238
|
+
AFFINE_BASE_URL: baseUrl,
|
|
239
|
+
AFFINE_API_TOKEN: result.token,
|
|
240
|
+
AFFINE_WORKSPACE_ID: result.workspaceId,
|
|
241
|
+
});
|
|
242
|
+
console.error(`\n✓ Saved to ${CONFIG_FILE} (mode 600)`);
|
|
243
|
+
console.error("The MCP server will use these credentials automatically.");
|
|
244
|
+
}
|
|
245
|
+
async function status() {
|
|
246
|
+
const config = loadConfigFile();
|
|
247
|
+
if (!config.AFFINE_API_TOKEN) {
|
|
248
|
+
throw new CliError("Not logged in. Run: affine-mcp login");
|
|
249
|
+
}
|
|
250
|
+
console.error(`Config: ${CONFIG_FILE}`);
|
|
251
|
+
console.error(`URL: ${config.AFFINE_BASE_URL || "(default)"}`);
|
|
252
|
+
console.error(`Token: (set)`);
|
|
253
|
+
console.error(`Workspace: ${config.AFFINE_WORKSPACE_ID || "(none)"}\n`);
|
|
254
|
+
try {
|
|
255
|
+
const data = await gql(config.AFFINE_BASE_URL || "https://app.affine.pro", { token: config.AFFINE_API_TOKEN }, "query { currentUser { name email } workspaces { id } }");
|
|
256
|
+
console.error(`User: ${data.currentUser.name} <${data.currentUser.email}>`);
|
|
257
|
+
console.error(`Workspaces: ${data.workspaces.length}`);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
throw new CliError(`Connection failed: ${err.message}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function logout() {
|
|
264
|
+
if (fs.existsSync(CONFIG_FILE)) {
|
|
265
|
+
fs.unlinkSync(CONFIG_FILE);
|
|
266
|
+
console.error(`Removed ${CONFIG_FILE}`);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
console.error("No config file found.");
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const COMMANDS = { login, status, logout };
|
|
273
|
+
export async function runCli(command) {
|
|
274
|
+
const fn = COMMANDS[command];
|
|
275
|
+
if (!fn)
|
|
276
|
+
return false;
|
|
277
|
+
try {
|
|
278
|
+
await fn();
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (err instanceof CliError) {
|
|
282
|
+
console.error(`✗ ${err.message}`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|