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 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
- [![Version](https://img.shields.io/badge/version-1.4.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-1.6.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
6
6
  [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-1.17.2-green)](https://github.com/modelcontextprotocol/typescript-sdk)
7
7
  [![CI](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml)
8
8
  [![License](https://img.shields.io/badge/license-MIT-yellow)](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: 32 focused tools with WebSocket-based document editing
19
+ - Tools: 43 focused tools with WebSocket-based document editing
20
20
  - Status: Active
21
21
 
22
- > New in v1.4.0: Added `read_doc` for document content snapshots (blocks + plain text), plus Cursor setup/troubleshooting guidance.
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 paragraph/delete (WebSocket‑based)
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
- Configure via environment variables (shell or app config). `.env` files are no longer recommended.
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://your-affine-instance.com",
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 `AFFINE_COOKIE` or `AFFINE_API_TOKEN` for zero‑latency startup.
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
- - Global install path (fastest)
100
- - `npm i -g affine-mcp-server`
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
- - Token or cookie (no startup login)
107
- - Token: `codex mcp add affine --env AFFINE_BASE_URL=https://... --env AFFINE_API_TOKEN=... -- affine-mcp`
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
- Notes
111
- - MCP name: `affine`
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://your-affine-instance.com",
128
- "AFFINE_API_TOKEN": "apt_xxx"
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://your-affine-instance.com",
145
- "AFFINE_API_TOKEN": "apt_xxx"
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 slash-command style blocks (`heading/list/todo/code/divider/quote`)
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
- - Email/Password: ensure your instance allows password auth and credentials are valid
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 res = await fetch(url, {
14
- method: "POST",
15
- headers: { "Content-Type": "application/json" },
16
- body: JSON.stringify({ email, password })
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 text = await res.text().catch(() => "");
20
- throw new Error(`Sign-in failed: ${res.status} ${text}`);
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
+ }