affine-mcp-server 1.9.0 → 1.10.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 (default) or HTTP (`/mcp`).
4
4
 
5
- [![Version](https://img.shields.io/badge/version-1.9.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-1.10.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,10 +16,10 @@ 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 (default) and optional HTTP (`/mcp`) for remote MCP deployments
18
18
  - Auth: Token, Cookie, or Email/Password (priority order)
19
- - Tools: 47 focused tools with WebSocket-based document editing
19
+ - Tools: 61 focused tools with WebSocket-based document editing
20
20
  - Status: Active
21
21
 
22
- > New in v1.9.0: Added database schema discovery, preset-backed data views, self-bootstrapping comprehensive regression, focused supporting-tools coverage, and markdown callout round-trips.
22
+ > New in v1.10.0: Added document search/discovery utilities, template and batch document workflows, optional OAuth-protected HTTP mode, richer CLI diagnostics, and an HTTP multi-session email/password auth fix.
23
23
 
24
24
  ## Features
25
25
 
@@ -93,7 +93,12 @@ The MCP server will use these credentials automatically.
93
93
  ```
94
94
 
95
95
  Other CLI commands:
96
+ - `affine-mcp --help` / `-h` / `help` — show command help
96
97
  - `affine-mcp status` — show current config and test connection
98
+ - `affine-mcp doctor` — run config and connectivity diagnostics
99
+ - `affine-mcp show-config` — print the effective config with secrets redacted
100
+ - `affine-mcp config-path` — print the config file path
101
+ - `affine-mcp snippet <claude|cursor|codex> [--env]` — print ready-to-paste client configuration snippets
97
102
  - `affine-mcp logout` — remove stored credentials
98
103
  - `affine-mcp --version` / `-v` / `version` — print the installed CLI version and exit
99
104
 
@@ -186,6 +191,8 @@ Or with email/password for self-hosted instances (not supported on AFFiNE Cloud
186
191
  Tips
187
192
  - Prefer `affine-mcp login` or `AFFINE_API_TOKEN` for zero‑latency startup.
188
193
  - If your password contains `!` (zsh history expansion), wrap it in single quotes in shells or use the JSON config above.
194
+ - `affine-mcp doctor` is the fastest way to confirm that your saved config still works.
195
+ - `affine-mcp snippet claude --env` and `affine-mcp snippet codex --env` can generate ready-to-paste client setup from your current config.
189
196
 
190
197
  ### Codex CLI
191
198
 
@@ -246,12 +253,16 @@ If you want to host the server remotely (e.g., using Render, Railway, Docker, or
246
253
  Required:
247
254
  - `MCP_TRANSPORT=http`
248
255
  - `AFFINE_BASE_URL` (example: `https://app.affine.pro`)
249
- - One auth method:
256
+ - `AFFINE_MCP_AUTH_MODE=bearer` (default) or `AFFINE_MCP_AUTH_MODE=oauth`
257
+
258
+ Bearer mode backend auth:
250
259
  - `AFFINE_API_TOKEN` (recommended), or `AFFINE_COOKIE`, or `AFFINE_EMAIL` + `AFFINE_PASSWORD`
251
260
 
261
+ OAuth mode backend auth:
262
+ - `AFFINE_API_TOKEN` (required service credential for AFFiNE backend access)
263
+
252
264
  Recommended for remote/public deployments:
253
265
  - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
254
- - `AFFINE_MCP_HTTP_TOKEN=<strong-random-token>` (protects `/mcp`, `/sse`, `/messages`)
255
266
  - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<comma-separated-origins>` (for browser clients)
256
267
 
257
268
  Optional:
@@ -260,31 +271,68 @@ Optional:
260
271
  - `AFFINE_GRAPHQL_PATH` (defaults to `/graphql`)
261
272
  - `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true` (testing only)
262
273
 
274
+ Bearer-mode only:
275
+ - `AFFINE_MCP_HTTP_TOKEN=<strong-random-token>` (protects `/mcp`, `/sse`, `/messages`)
276
+
277
+ OAuth-mode only:
278
+ - `AFFINE_MCP_PUBLIC_BASE_URL=https://mcp.yourdomain.com`
279
+ - `AFFINE_OAUTH_ISSUER_URL=https://auth.yourdomain.com`
280
+ - `AFFINE_OAUTH_SCOPES=mcp` (defaults to `mcp`)
281
+
282
+ #### HTTP auth modes
283
+
284
+ `AFFINE_MCP_AUTH_MODE=bearer` keeps the current static bearer-token behavior.
285
+
263
286
  ```bash
264
- # Export your configuration first
265
287
  export MCP_TRANSPORT=http
288
+ export AFFINE_MCP_AUTH_MODE=bearer
266
289
  export AFFINE_API_TOKEN="your_token..."
267
- export AFFINE_MCP_HTTP_HOST="0.0.0.0" # Default: 127.0.0.1
290
+ export AFFINE_MCP_HTTP_HOST="0.0.0.0"
268
291
  export AFFINE_MCP_HTTP_TOKEN="your-super-secret-token"
269
292
  export PORT=3000
270
293
 
271
- # Start in HTTP mode (Streamable HTTP on /mcp)
272
294
  npm run start:http
273
- # OR manually:
274
- # MCP_TRANSPORT=http node dist/index.js
275
- # ("sse" is still accepted at /sse)
276
295
  ```
277
296
 
297
+ `AFFINE_MCP_AUTH_MODE=oauth` turns the MCP endpoint into an OAuth-protected resource for web MCP clients. In this mode:
298
+ - the server exposes `/.well-known/oauth-protected-resource`
299
+ - unauthenticated `/mcp` requests return `401` with a `WWW-Authenticate` challenge
300
+ - `AFFINE_MCP_HTTP_TOKEN` and `?token=` are disabled
301
+ - `sign_in` is not registered
302
+ - `AFFINE_API_TOKEN` is still required so the server can call AFFiNE as a service credential
303
+
304
+ ```bash
305
+ export MCP_TRANSPORT=http
306
+ export AFFINE_MCP_AUTH_MODE=oauth
307
+ export AFFINE_API_TOKEN="your-affine-service-token"
308
+ export AFFINE_MCP_HTTP_HOST="0.0.0.0"
309
+ export AFFINE_MCP_PUBLIC_BASE_URL="https://mcp.yourdomain.com"
310
+ export AFFINE_OAUTH_ISSUER_URL="https://auth.yourdomain.com"
311
+ export AFFINE_OAUTH_SCOPES="mcp"
312
+ export PORT=3000
313
+
314
+ npm run start:http
315
+ ```
316
+
317
+ Notes for oauth mode:
318
+ - use HTTPS for non-local deployments
319
+ - `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true` is rejected in oauth mode
320
+ - tokens are validated against the issuer discovery metadata and JWKS
321
+ - the protected resource metadata is also served at `/.well-known/oauth-protected-resource/mcp` for path-specific discovery
322
+ - `GET /healthz` and `GET /readyz` are available for deployment diagnostics
323
+
278
324
  #### Recommended presets
279
325
 
280
326
  Local testing (HTTP mode):
281
327
  - `MCP_TRANSPORT=http`
328
+ - `AFFINE_MCP_AUTH_MODE=bearer`
282
329
  - `AFFINE_MCP_HTTP_HOST=127.0.0.1`
283
330
  - `AFFINE_MCP_HTTP_TOKEN=<token>` (recommended even locally)
284
331
  - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=http://localhost:3000` (if testing from a browser app)
285
332
 
286
333
  Docker / container runtime:
287
334
  - `MCP_TRANSPORT=http`
335
+ - `AFFINE_MCP_AUTH_MODE=bearer`
288
336
  - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
289
337
  - `PORT=3000` (or container/platform port)
290
338
  - `AFFINE_MCP_HTTP_TOKEN=<strong-token>`
@@ -292,14 +340,19 @@ Docker / container runtime:
292
340
 
293
341
  Render / Railway / VPS (public endpoint):
294
342
  - `MCP_TRANSPORT=http`
343
+ - `AFFINE_MCP_AUTH_MODE=bearer` or `oauth`
295
344
  - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
296
- - `AFFINE_MCP_HTTP_TOKEN=<strong-token>`
345
+ - `AFFINE_MCP_HTTP_TOKEN=<strong-token>` (bearer mode)
346
+ - `AFFINE_MCP_PUBLIC_BASE_URL=<public base URL>` (oauth mode)
347
+ - `AFFINE_OAUTH_ISSUER_URL=<issuer URL>` (oauth mode)
297
348
  - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<your client origin(s)>`
298
349
 
299
350
  Endpoints currently available:
300
351
  - `/mcp` - MCP server (Streamable HTTP)
301
352
  - `/sse` - SSE endpoint (old protocol compatible)
302
353
  - `/messages` - Messages endpoint (old protocol compatible)
354
+ - `/healthz` - HTTP liveness probe
355
+ - `/readyz` - HTTP readiness probe
303
356
 
304
357
  ## Available Tools
305
358
 
@@ -313,6 +366,7 @@ Endpoints currently available:
313
366
  ### Documents
314
367
  - `list_docs` – list documents with pagination (includes `node.tags`)
315
368
  - `list_tags` – list all tags in a workspace
369
+ - `search_docs` – fast title search with substring/prefix/exact matching, optional tag filtering, and updatedAt sorting
316
370
  - `list_docs_by_tag` – list documents by tag
317
371
  - `get_doc` – get document metadata
318
372
  - `read_doc` – read document block content and plain text snapshot (WebSocket)
@@ -380,7 +434,7 @@ npm run pack:check
380
434
  - For full tool-surface verification, run `npm run test:comprehensive` (self-bootstraps a local Docker AFFiNE stack).
381
435
  - For pre-provisioned environments, use `npm run test:comprehensive:raw`.
382
436
  - For full environment verification, run `npm run test:e2e` (Docker + MCP + Playwright).
383
- - Additional focused runners: `npm run test:db-create`, `npm run test:db-cells`, `npm run test:db-schema`, `npm run test:supporting-tools`, `npm run test:bearer`, `npm run test:cli-version`, `npm run test:playwright`.
437
+ - Additional focused runners: `npm run test:db-create`, `npm run test:db-cells`, `npm run test:db-schema`, `npm run test:supporting-tools`, `npm run test:bearer`, `npm run test:http-email-password`, `npm run test:http-bearer`, `npm run test:oauth-http`, `npm run test:doc-discovery`, `npm run test:cli-version`, `npm run test:cli-commands`, `npm run test:playwright`.
384
438
 
385
439
  ## Troubleshooting
386
440
 
package/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { fetch } from "undici";
2
2
  import * as fs from "fs";
3
3
  import * as readline from "readline";
4
- import { CONFIG_FILE, loadConfigFile, writeConfigFile, validateBaseUrl, VERSION } from "./config.js";
4
+ import { CONFIG_FILE, loadConfig, loadConfigFile, validateBaseUrl, VERSION, writeConfigFile } from "./config.js";
5
5
  import { loginWithPassword } from "./auth.js";
6
6
  const CLI_FETCH_TIMEOUT_MS = 30_000;
7
7
  class CliError extends Error {
@@ -42,12 +42,12 @@ function readHidden(prompt) {
42
42
  process.stderr.write("\n");
43
43
  resolve(buf.join(""));
44
44
  break;
45
- case "\u0003": // Ctrl-C
45
+ case "\u0003":
46
46
  cleanup();
47
47
  process.stderr.write("\n");
48
48
  reject(new CliError("Aborted."));
49
49
  break;
50
- case "\u007F": // Backspace
50
+ case "\u007F":
51
51
  case "\b":
52
52
  buf.pop();
53
53
  break;
@@ -69,9 +69,9 @@ async function gql(baseUrl, auth, query, variables) {
69
69
  "User-Agent": `affine-mcp-server/${VERSION}`,
70
70
  };
71
71
  if (auth.token)
72
- headers["Authorization"] = `Bearer ${auth.token}`;
72
+ headers.Authorization = `Bearer ${auth.token}`;
73
73
  if (auth.cookie)
74
- headers["Cookie"] = auth.cookie;
74
+ headers.Cookie = auth.cookie;
75
75
  const body = { query };
76
76
  if (variables)
77
77
  body.variables = variables;
@@ -87,8 +87,9 @@ async function gql(baseUrl, auth, query, variables) {
87
87
  });
88
88
  }
89
89
  catch (err) {
90
- if (err.name === "AbortError")
90
+ if (err.name === "AbortError") {
91
91
  throw new Error(`Request timed out after ${CLI_FETCH_TIMEOUT_MS / 1000}s`);
92
+ }
92
93
  throw err;
93
94
  }
94
95
  finally {
@@ -101,6 +102,112 @@ async function gql(baseUrl, auth, query, variables) {
101
102
  throw new Error(json.errors.map((e) => e.message).join("; "));
102
103
  return json.data;
103
104
  }
105
+ function parseFlag(args, ...flags) {
106
+ return args.some((arg) => flags.includes(arg));
107
+ }
108
+ function ensureNoUnexpectedArgs(args, command) {
109
+ if (args.length > 0) {
110
+ throw new CliError(`Unexpected arguments for '${command}': ${args.join(" ")}`);
111
+ }
112
+ }
113
+ function redactSecret(value) {
114
+ if (!value)
115
+ return null;
116
+ if (value.length <= 8)
117
+ return "*".repeat(value.length);
118
+ return `${value.slice(0, 4)}…${value.slice(-4)}`;
119
+ }
120
+ function getConfigValueSource(name, file, fallback) {
121
+ if (process.env[name])
122
+ return "env";
123
+ if (file[name])
124
+ return "config";
125
+ if (fallback !== undefined)
126
+ return "default";
127
+ return "unset";
128
+ }
129
+ function buildEffectiveConfigSummary() {
130
+ const stored = loadConfigFile();
131
+ const effective = loadConfig();
132
+ const authKind = effective.apiToken
133
+ ? "api-token"
134
+ : effective.cookie
135
+ ? "cookie"
136
+ : effective.email && effective.password
137
+ ? "email-password"
138
+ : "none";
139
+ return {
140
+ configFile: CONFIG_FILE,
141
+ configFileExists: fs.existsSync(CONFIG_FILE),
142
+ baseUrl: effective.baseUrl,
143
+ graphqlPath: effective.graphqlPath,
144
+ workspaceId: effective.defaultWorkspaceId || null,
145
+ authMode: effective.authMode,
146
+ authKind,
147
+ apiToken: effective.apiToken ? redactSecret(effective.apiToken) : null,
148
+ cookie: effective.cookie ? "(set)" : null,
149
+ email: effective.email || null,
150
+ publicBaseUrl: effective.publicBaseUrl || null,
151
+ oauthIssuerUrl: effective.oauthIssuerUrl || null,
152
+ oauthScopes: effective.oauthScopes,
153
+ sources: {
154
+ baseUrl: getConfigValueSource("AFFINE_BASE_URL", stored, "http://localhost:3010"),
155
+ apiToken: getConfigValueSource("AFFINE_API_TOKEN", stored),
156
+ cookie: getConfigValueSource("AFFINE_COOKIE", stored),
157
+ email: getConfigValueSource("AFFINE_EMAIL", stored),
158
+ password: getConfigValueSource("AFFINE_PASSWORD", stored),
159
+ workspaceId: getConfigValueSource("AFFINE_WORKSPACE_ID", stored),
160
+ authMode: getConfigValueSource("AFFINE_MCP_AUTH_MODE", stored, "bearer"),
161
+ publicBaseUrl: getConfigValueSource("AFFINE_MCP_PUBLIC_BASE_URL", stored),
162
+ oauthIssuerUrl: getConfigValueSource("AFFINE_OAUTH_ISSUER_URL", stored),
163
+ oauthScopes: getConfigValueSource("AFFINE_OAUTH_SCOPES", stored, "mcp"),
164
+ },
165
+ };
166
+ }
167
+ async function resolveCliAuth(baseUrl) {
168
+ const effective = loadConfig();
169
+ if (effective.apiToken) {
170
+ return { auth: { token: effective.apiToken }, authKind: "api-token" };
171
+ }
172
+ if (effective.cookie) {
173
+ return { auth: { cookie: effective.cookie }, authKind: "cookie" };
174
+ }
175
+ if (effective.email && effective.password) {
176
+ const { cookieHeader } = await loginWithPassword(baseUrl, effective.email, effective.password);
177
+ return { auth: { cookie: cookieHeader }, authKind: "email-password" };
178
+ }
179
+ throw new CliError("No authentication configured. Run 'affine-mcp login' or set AFFINE_API_TOKEN.");
180
+ }
181
+ function printHelp(command) {
182
+ if (command) {
183
+ const definition = COMMANDS[command];
184
+ if (!definition) {
185
+ throw new CliError(`Unknown command '${command}'.`);
186
+ }
187
+ console.log(`${definition.usage}\n`);
188
+ console.log(definition.summary);
189
+ return;
190
+ }
191
+ console.log(`affine-mcp ${VERSION}`);
192
+ console.log("");
193
+ console.log("Usage:");
194
+ console.log(" affine-mcp Start the MCP server over stdio");
195
+ console.log(" affine-mcp <command> Run a CLI command");
196
+ console.log("");
197
+ console.log("Commands:");
198
+ for (const [name, definition] of Object.entries(COMMANDS)) {
199
+ console.log(` ${name.padEnd(12)} ${definition.summary}`);
200
+ }
201
+ console.log("");
202
+ console.log("Common examples:");
203
+ console.log(" affine-mcp login");
204
+ console.log(" affine-mcp status");
205
+ console.log(" affine-mcp doctor");
206
+ console.log(" affine-mcp show-config --json");
207
+ console.log(" affine-mcp snippet claude --env");
208
+ console.log(" affine-mcp --version");
209
+ console.log(" affine-mcp --help");
210
+ }
104
211
  async function detectWorkspace(baseUrl, auth) {
105
212
  console.error("Detecting workspaces...");
106
213
  try {
@@ -157,7 +264,6 @@ async function loginWithEmail(baseUrl) {
157
264
  catch (err) {
158
265
  throw new CliError(`Sign-in failed: ${err.message}`);
159
266
  }
160
- // Verify identity
161
267
  const auth = { cookie: cookieHeader };
162
268
  try {
163
269
  const data = await gql(baseUrl, auth, "query { currentUser { name email } }");
@@ -166,7 +272,6 @@ async function loginWithEmail(baseUrl) {
166
272
  catch (err) {
167
273
  throw new CliError(`Session verification failed: ${err.message}`);
168
274
  }
169
- // Auto-generate an API token so the MCP server can use token auth (no cookie expiry issues)
170
275
  console.error("Generating API token...");
171
276
  let token;
172
277
  try {
@@ -201,13 +306,13 @@ async function loginWithToken(baseUrl) {
201
306
  const workspaceId = await detectWorkspace(baseUrl, { token });
202
307
  return { token, workspaceId };
203
308
  }
204
- async function login() {
309
+ async function login(_args) {
205
310
  console.error("Affine MCP Server — Login\n");
206
311
  const existing = loadConfigFile();
207
312
  if (existing.AFFINE_API_TOKEN) {
208
313
  console.error(`Existing config: ${CONFIG_FILE}`);
209
314
  console.error(` URL: ${existing.AFFINE_BASE_URL || "(default)"}`);
210
- console.error(` Token: (set)`);
315
+ console.error(" Token: (set)");
211
316
  console.error(` Workspace: ${existing.AFFINE_WORKSPACE_ID || "(none)"}\n`);
212
317
  const overwrite = await ask("Overwrite? [y/N] ");
213
318
  if (!/^[yY]$/.test(overwrite)) {
@@ -223,15 +328,9 @@ async function login() {
223
328
  let result;
224
329
  if (isSelfHosted) {
225
330
  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
- }
331
+ result = method === "2" ? await loginWithToken(baseUrl) : await loginWithEmail(baseUrl);
232
332
  }
233
333
  else {
234
- // Cloudflare blocks programmatic sign-in on app.affine.pro — token is the only option
235
334
  result = await loginWithToken(baseUrl);
236
335
  }
237
336
  writeConfigFile({
@@ -242,14 +341,15 @@ async function login() {
242
341
  console.error(`\n✓ Saved to ${CONFIG_FILE} (mode 600)`);
243
342
  console.error("The MCP server will use these credentials automatically.");
244
343
  }
245
- async function status() {
344
+ async function status(args) {
345
+ ensureNoUnexpectedArgs(args, "status");
246
346
  const config = loadConfigFile();
247
347
  if (!config.AFFINE_API_TOKEN) {
248
348
  throw new CliError("Not logged in. Run: affine-mcp login");
249
349
  }
250
350
  console.error(`Config: ${CONFIG_FILE}`);
251
351
  console.error(`URL: ${config.AFFINE_BASE_URL || "(default)"}`);
252
- console.error(`Token: (set)`);
352
+ console.error("Token: (set)");
253
353
  console.error(`Workspace: ${config.AFFINE_WORKSPACE_ID || "(none)"}\n`);
254
354
  try {
255
355
  const data = await gql(config.AFFINE_BASE_URL || "https://app.affine.pro", { token: config.AFFINE_API_TOKEN }, "query { currentUser { name email } workspaces { id } }");
@@ -260,7 +360,8 @@ async function status() {
260
360
  throw new CliError(`Connection failed: ${err.message}`);
261
361
  }
262
362
  }
263
- function logout() {
363
+ function logout(args) {
364
+ ensureNoUnexpectedArgs(args, "logout");
264
365
  if (fs.existsSync(CONFIG_FILE)) {
265
366
  fs.unlinkSync(CONFIG_FILE);
266
367
  console.error(`Removed ${CONFIG_FILE}`);
@@ -269,13 +370,248 @@ function logout() {
269
370
  console.error("No config file found.");
270
371
  }
271
372
  }
272
- const COMMANDS = { login, status, logout };
273
- export async function runCli(command) {
274
- const fn = COMMANDS[command];
275
- if (!fn)
373
+ function configPath(args) {
374
+ ensureNoUnexpectedArgs(args, "config-path");
375
+ console.log(CONFIG_FILE);
376
+ }
377
+ function showConfig(args) {
378
+ const asJson = parseFlag(args, "--json");
379
+ const unexpectedArgs = args.filter((arg) => arg !== "--json");
380
+ if (unexpectedArgs.length > 0) {
381
+ throw new CliError(`Unexpected arguments for 'show-config': ${unexpectedArgs.join(" ")}`);
382
+ }
383
+ const summary = buildEffectiveConfigSummary();
384
+ if (asJson) {
385
+ console.log(JSON.stringify(summary, null, 2));
386
+ return;
387
+ }
388
+ console.log(`Config file: ${summary.configFile} (${summary.configFileExists ? "found" : "missing"})`);
389
+ console.log(`Base URL: ${summary.baseUrl} (${summary.sources.baseUrl})`);
390
+ console.log(`GraphQL path: ${summary.graphqlPath}`);
391
+ console.log(`Auth mode: ${summary.authMode} (${summary.sources.authMode})`);
392
+ console.log(`Auth kind: ${summary.authKind}`);
393
+ console.log(`Workspace: ${summary.workspaceId || "(none)"} (${summary.sources.workspaceId})`);
394
+ if (summary.apiToken)
395
+ console.log(`API token: ${summary.apiToken} (${summary.sources.apiToken})`);
396
+ if (summary.cookie)
397
+ console.log(`Cookie: ${summary.cookie} (${summary.sources.cookie})`);
398
+ if (summary.email)
399
+ console.log(`Email: ${summary.email} (${summary.sources.email})`);
400
+ if (summary.publicBaseUrl)
401
+ console.log(`Public base URL: ${summary.publicBaseUrl} (${summary.sources.publicBaseUrl})`);
402
+ if (summary.oauthIssuerUrl)
403
+ console.log(`OAuth issuer URL: ${summary.oauthIssuerUrl} (${summary.sources.oauthIssuerUrl})`);
404
+ if (summary.authMode === "oauth")
405
+ console.log(`OAuth scopes: ${summary.oauthScopes.join(", ")} (${summary.sources.oauthScopes})`);
406
+ }
407
+ async function doctor(args) {
408
+ const asJson = parseFlag(args, "--json");
409
+ const unexpectedArgs = args.filter((arg) => arg !== "--json");
410
+ if (unexpectedArgs.length > 0) {
411
+ throw new CliError(`Unexpected arguments for 'doctor': ${unexpectedArgs.join(" ")}`);
412
+ }
413
+ const summary = buildEffectiveConfigSummary();
414
+ const checks = [];
415
+ checks.push({
416
+ name: "config-file",
417
+ ok: summary.configFileExists,
418
+ detail: summary.configFileExists ? summary.configFile : "No saved config file found",
419
+ });
420
+ let authKind = "none";
421
+ try {
422
+ const { auth, authKind: resolvedAuthKind } = await resolveCliAuth(summary.baseUrl);
423
+ authKind = resolvedAuthKind;
424
+ checks.push({
425
+ name: "auth-configured",
426
+ ok: true,
427
+ detail: `Using ${resolvedAuthKind}`,
428
+ });
429
+ const healthController = new AbortController();
430
+ const healthTimer = setTimeout(() => healthController.abort(), CLI_FETCH_TIMEOUT_MS);
431
+ try {
432
+ const response = await fetch(summary.baseUrl, { signal: healthController.signal });
433
+ checks.push({
434
+ name: "base-url",
435
+ ok: response.ok,
436
+ detail: `HTTP ${response.status}`,
437
+ });
438
+ }
439
+ catch (err) {
440
+ checks.push({
441
+ name: "base-url",
442
+ ok: false,
443
+ detail: err?.message || "Could not reach base URL",
444
+ });
445
+ }
446
+ finally {
447
+ clearTimeout(healthTimer);
448
+ }
449
+ try {
450
+ const data = await gql(summary.baseUrl, auth, "query { currentUser { name email } workspaces { id } }");
451
+ checks.push({
452
+ name: "graphql-auth",
453
+ ok: true,
454
+ detail: `${data.currentUser.email} (${data.workspaces.length} workspace(s))`,
455
+ });
456
+ }
457
+ catch (err) {
458
+ checks.push({
459
+ name: "graphql-auth",
460
+ ok: false,
461
+ detail: err?.message || "GraphQL auth failed",
462
+ });
463
+ }
464
+ }
465
+ catch (err) {
466
+ checks.push({
467
+ name: "auth-configured",
468
+ ok: false,
469
+ detail: err?.message || "No authentication configured",
470
+ });
471
+ }
472
+ if (summary.authMode === "oauth") {
473
+ const oauthReady = Boolean(summary.publicBaseUrl && summary.oauthIssuerUrl && summary.oauthScopes.length > 0);
474
+ checks.push({
475
+ name: "oauth-config",
476
+ ok: oauthReady,
477
+ detail: oauthReady
478
+ ? `${summary.publicBaseUrl} -> ${summary.oauthIssuerUrl}`
479
+ : "OAuth mode requires AFFINE_MCP_PUBLIC_BASE_URL and AFFINE_OAUTH_ISSUER_URL",
480
+ });
481
+ }
482
+ const ok = checks.every((check) => check.ok);
483
+ if (asJson) {
484
+ console.log(JSON.stringify({
485
+ ok,
486
+ config: summary,
487
+ checks,
488
+ authKind,
489
+ }, null, 2));
490
+ if (!ok)
491
+ process.exit(1);
492
+ return;
493
+ }
494
+ console.log(`Doctor: ${ok ? "OK" : "FAILED"}`);
495
+ console.log(`Base URL: ${summary.baseUrl}`);
496
+ console.log(`Auth mode: ${summary.authMode}`);
497
+ for (const check of checks) {
498
+ console.log(`${check.ok ? "✓" : "✗"} ${check.name}: ${check.detail}`);
499
+ }
500
+ if (!ok) {
501
+ throw new CliError("Doctor checks failed.");
502
+ }
503
+ }
504
+ function getSnippetEnv() {
505
+ const effective = loadConfig();
506
+ const env = {};
507
+ if (effective.baseUrl)
508
+ env.AFFINE_BASE_URL = effective.baseUrl;
509
+ if (effective.apiToken)
510
+ env.AFFINE_API_TOKEN = effective.apiToken;
511
+ if (effective.defaultWorkspaceId)
512
+ env.AFFINE_WORKSPACE_ID = effective.defaultWorkspaceId;
513
+ if (effective.authMode === "oauth") {
514
+ env.AFFINE_MCP_AUTH_MODE = "oauth";
515
+ if (effective.publicBaseUrl)
516
+ env.AFFINE_MCP_PUBLIC_BASE_URL = effective.publicBaseUrl;
517
+ if (effective.oauthIssuerUrl)
518
+ env.AFFINE_OAUTH_ISSUER_URL = effective.oauthIssuerUrl;
519
+ if (effective.oauthScopes.length > 0)
520
+ env.AFFINE_OAUTH_SCOPES = effective.oauthScopes.join(" ");
521
+ }
522
+ return env;
523
+ }
524
+ function snippet(args) {
525
+ const target = args[0];
526
+ if (!target) {
527
+ throw new CliError("Usage: affine-mcp snippet <claude|cursor|codex> [--env]");
528
+ }
529
+ const includeEnv = parseFlag(args, "--env");
530
+ const unexpectedArgs = args.slice(1).filter((arg) => arg !== "--env");
531
+ if (unexpectedArgs.length > 0) {
532
+ throw new CliError(`Unexpected arguments for 'snippet': ${unexpectedArgs.join(" ")}`);
533
+ }
534
+ const env = includeEnv ? getSnippetEnv() : undefined;
535
+ if (target === "claude" || target === "cursor") {
536
+ const payload = {
537
+ mcpServers: {
538
+ affine: {
539
+ command: "affine-mcp",
540
+ ...(env && Object.keys(env).length > 0 ? { env } : {}),
541
+ },
542
+ },
543
+ };
544
+ console.log(JSON.stringify(payload, null, 2));
545
+ return;
546
+ }
547
+ if (target === "codex") {
548
+ if (!env || Object.keys(env).length === 0) {
549
+ console.log("codex mcp add affine -- affine-mcp");
550
+ return;
551
+ }
552
+ const envArgs = Object.entries(env)
553
+ .map(([key, value]) => `--env ${key}=${JSON.stringify(value)}`)
554
+ .join(" ");
555
+ console.log(`codex mcp add affine ${envArgs} -- affine-mcp`);
556
+ return;
557
+ }
558
+ throw new CliError(`Unknown snippet target '${target}'. Expected claude, cursor, or codex.`);
559
+ }
560
+ function help(args) {
561
+ if (args.length > 1) {
562
+ throw new CliError("Usage: affine-mcp help [command]");
563
+ }
564
+ printHelp(args[0]);
565
+ }
566
+ const COMMANDS = {
567
+ help: {
568
+ summary: "Show CLI help",
569
+ usage: "affine-mcp help [command]",
570
+ handler: help,
571
+ },
572
+ login: {
573
+ summary: "Interactive login and config bootstrap",
574
+ usage: "affine-mcp login",
575
+ handler: login,
576
+ },
577
+ status: {
578
+ summary: "Test the saved config and print current user info",
579
+ usage: "affine-mcp status",
580
+ handler: status,
581
+ },
582
+ logout: {
583
+ summary: "Remove the saved config file",
584
+ usage: "affine-mcp logout",
585
+ handler: logout,
586
+ },
587
+ "config-path": {
588
+ summary: "Print the config file path",
589
+ usage: "affine-mcp config-path",
590
+ handler: configPath,
591
+ },
592
+ "show-config": {
593
+ summary: "Print the effective config (redacted)",
594
+ usage: "affine-mcp show-config [--json]",
595
+ handler: showConfig,
596
+ },
597
+ doctor: {
598
+ summary: "Run local config and connectivity diagnostics",
599
+ usage: "affine-mcp doctor [--json]",
600
+ handler: doctor,
601
+ },
602
+ snippet: {
603
+ summary: "Print ready-to-paste Claude/Cursor/Codex snippets",
604
+ usage: "affine-mcp snippet <claude|cursor|codex> [--env]",
605
+ handler: snippet,
606
+ },
607
+ };
608
+ export async function runCli(command, args = []) {
609
+ const normalizedCommand = command.trim().toLowerCase();
610
+ const definition = COMMANDS[normalizedCommand];
611
+ if (!definition)
276
612
  return false;
277
613
  try {
278
- await fn();
614
+ await definition.handler(args);
279
615
  }
280
616
  catch (err) {
281
617
  if (err instanceof CliError) {