affine-mcp-server 1.10.0 → 1.11.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.10.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-1.11.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,16 +16,17 @@ 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: 61 focused tools with WebSocket-based document editing
19
+ - Tools: 76 focused tools with WebSocket-based document editing
20
20
  - Status: Active
21
21
 
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.
22
+ > New in v1.11.0: Added sidebar organize tools, configurable tool filtering, `delete_database_row`, and richer markdown import formatting for lists and table cells.
23
23
 
24
24
  ## Features
25
25
 
26
26
  - Workspace: create (with initial doc), read, update, delete
27
27
  - Documents: list/get/read/publish/revoke + create/append/replace/delete + markdown import/export + tags (WebSocket‑based)
28
- - Database workflows: create database blocks, inspect schema, add columns and rows, and read or update cell values via MCP tools
28
+ - Sidebar data: collections, folders, and organize links for AFFiNE workspace trees
29
+ - Database workflows: create database blocks, inspect schema, add/update/delete rows, and read or update cell values via MCP tools
29
30
  - Comments: full CRUD and resolve
30
31
  - Version History: list
31
32
  - Users & Tokens: current user, sign in, profile/settings, and personal access tokens
@@ -95,13 +96,17 @@ The MCP server will use these credentials automatically.
95
96
  Other CLI commands:
96
97
  - `affine-mcp --help` / `-h` / `help` — show command help
97
98
  - `affine-mcp status` — show current config and test connection
99
+ - `affine-mcp status --json` — machine-readable status output
98
100
  - `affine-mcp doctor` — run config and connectivity diagnostics
99
101
  - `affine-mcp show-config` — print the effective config with secrets redacted
100
102
  - `affine-mcp config-path` — print the config file path
101
- - `affine-mcp snippet <claude|cursor|codex> [--env]` — print ready-to-paste client configuration snippets
103
+ - `affine-mcp snippet <claude|cursor|codex|all> [--env]` — print ready-to-paste client configuration snippets
102
104
  - `affine-mcp logout` — remove stored credentials
103
105
  - `affine-mcp --version` / `-v` / `version` — print the installed CLI version and exit
104
106
 
107
+ Non-interactive login helpers:
108
+ - `affine-mcp login --url <url> --token <token> --workspace-id <id> --force`
109
+
105
110
  ### Environment variables
106
111
 
107
112
  You can also configure via environment variables (they override the config file):
@@ -109,6 +114,7 @@ You can also configure via environment variables (they override the config file)
109
114
  - Required: `AFFINE_BASE_URL`
110
115
  - Auth (choose one): `AFFINE_API_TOKEN` | `AFFINE_COOKIE` | `AFFINE_EMAIL` + `AFFINE_PASSWORD`
111
116
  - Optional: `AFFINE_GRAPHQL_PATH` (default `/graphql`), `AFFINE_WORKSPACE_ID`, `AFFINE_LOGIN_AT_START` (set `sync` only when you must block startup)
117
+ - Tool filtering: `AFFINE_DISABLED_GROUPS`, `AFFINE_DISABLED_TOOLS` (see [Filtering Exposed Tools](#filtering-exposed-tools))
112
118
 
113
119
  Authentication priority:
114
120
  1) `AFFINE_API_TOKEN` → 2) `AFFINE_COOKIE` → 3) `AFFINE_EMAIL` + `AFFINE_PASSWORD`
@@ -193,6 +199,7 @@ Tips
193
199
  - If your password contains `!` (zsh history expansion), wrap it in single quotes in shells or use the JSON config above.
194
200
  - `affine-mcp doctor` is the fastest way to confirm that your saved config still works.
195
201
  - `affine-mcp snippet claude --env` and `affine-mcp snippet codex --env` can generate ready-to-paste client setup from your current config.
202
+ - `affine-mcp snippet all --env` prints Claude, Cursor, and Codex setup in one shot.
196
203
 
197
204
  ### Codex CLI
198
205
 
@@ -314,9 +321,9 @@ export PORT=3000
314
321
  npm run start:http
315
322
  ```
316
323
 
317
- Notes for oauth mode:
324
+ Notes for OAuth mode:
318
325
  - use HTTPS for non-local deployments
319
- - `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true` is rejected in oauth mode
326
+ - `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS=true` is rejected in OAuth mode
320
327
  - tokens are validated against the issuer discovery metadata and JWKS
321
328
  - the protected resource metadata is also served at `/.well-known/oauth-protected-resource/mcp` for path-specific discovery
322
329
  - `GET /healthz` and `GET /readyz` are available for deployment diagnostics
@@ -343,8 +350,8 @@ Render / Railway / VPS (public endpoint):
343
350
  - `AFFINE_MCP_AUTH_MODE=bearer` or `oauth`
344
351
  - `AFFINE_MCP_HTTP_HOST=0.0.0.0`
345
352
  - `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)
353
+ - `AFFINE_MCP_PUBLIC_BASE_URL=<public base URL>` (OAuth mode)
354
+ - `AFFINE_OAUTH_ISSUER_URL=<issuer URL>` (OAuth mode)
348
355
  - `AFFINE_MCP_HTTP_ALLOWED_ORIGINS=<your client origin(s)>`
349
356
 
350
357
  Endpoints currently available:
@@ -354,6 +361,7 @@ Endpoints currently available:
354
361
  - `/healthz` - HTTP liveness probe
355
362
  - `/readyz` - HTTP readiness probe
356
363
 
364
+
357
365
  ## Available Tools
358
366
 
359
367
  ### Workspace
@@ -362,32 +370,63 @@ Endpoints currently available:
362
370
  - `create_workspace` – create workspace with initial document
363
371
  - `update_workspace` – update workspace settings
364
372
  - `delete_workspace` – delete workspace permanently
373
+ - `list_workspace_tree` – return the workspace document hierarchy as a tree
374
+ - `get_orphan_docs` – find documents that are not linked from any parent doc in the sidebar tree
375
+
376
+ ### Organization
377
+ - `list_collections` – list workspace collections
378
+ - `get_collection` – get a collection by id
379
+ - `create_collection` – create a collection
380
+ - `update_collection` – rename a collection
381
+ - `delete_collection` – delete a collection
382
+ - `add_doc_to_collection` – add a document to a collection allow-list
383
+ - `remove_doc_from_collection` – remove a document from a collection allow-list
384
+ - `list_organize_nodes` – experimental organize/folder tree dump
385
+ - `create_folder` – experimental root or nested folder creation
386
+ - `rename_folder` – experimental folder rename
387
+ - `delete_folder` – experimental recursive folder delete
388
+ - `move_organize_node` – experimental folder/link move
389
+ - `add_organize_link` – experimental doc/tag/collection link under a folder
390
+ - `delete_organize_link` – experimental doc/tag/collection link delete
391
+
365
392
 
366
393
  ### Documents
367
394
  - `list_docs` – list documents with pagination (includes `node.tags`)
368
395
  - `list_tags` – list all tags in a workspace
369
396
  - `search_docs` – fast title search with substring/prefix/exact matching, optional tag filtering, and updatedAt sorting
370
- - `list_docs_by_tag` – list documents by tag
397
+ - `list_docs_by_tag` – list documents that contain the requested tag
398
+ - `get_docs_by_tag` – discover documents by case-insensitive tag substring and return `availableTags` when nothing matches
371
399
  - `get_doc` – get document metadata
400
+ - `get_doc_by_title` – find a document by title and return its Markdown content
372
401
  - `read_doc` – read document block content and plain text snapshot (WebSocket)
373
402
  - `export_doc_markdown` – export document content as markdown
374
403
  - `publish_doc` – make document public
375
404
  - `revoke_doc` – revoke public access
376
405
  - `create_doc` – create a new document (WebSocket)
377
406
  - `create_doc_from_markdown` – create a document from markdown content
407
+ - `create_doc_from_template` – clone a template doc, substitute `{{variables}}`, and optionally link it under a parent doc
408
+ - `duplicate_doc` – clone a document into a new doc, optionally under a parent doc
378
409
  - `create_tag` – create a reusable workspace-level tag
379
410
  - `add_tag_to_doc` – attach a tag to a document
380
411
  - `remove_tag_from_doc` – detach a tag from a document
412
+ - `update_doc_title` – rename a document in both workspace metadata and the internal page block
381
413
  - `append_paragraph` – append a paragraph block (WebSocket)
382
414
  - `append_block` – append canonical block types (text/list/code/media/embed/database/edgeless) with strict validation and placement control (`viewMode=kanban` enables preset-backed data views; `data_view` defaults to kanban)
415
+ - `move_doc` – move a document in the sidebar by relinking it under a different parent
416
+ - `batch_create_docs` – create up to 20 documents in a single call
383
417
  - `add_database_column` – add a column to a database block (`rich-text`, `select`, `multi-select`, `number`, `checkbox`, `link`, `date`)
384
418
  - `add_database_row` – add a row to a database block with values mapped by column name/ID (`title` / `Title` updates the built-in row title)
419
+ - `delete_database_row` – delete a row from a database block by row block id
385
420
  - `read_database_columns` – read database schema metadata including column IDs/types, select options, and table view column mappings
386
421
  - `read_database_cells` – read row titles plus decoded database cell values with optional row / column filters
387
422
  - `update_database_cell` – update a single database cell or the built-in row title (`createOption` defaults to `true` for select fields)
388
423
  - `update_database_row` – batch update multiple cells on a database row (`createOption` defaults to `true` for select fields)
389
424
  - `append_markdown` – append markdown content to an existing document
390
425
  - `replace_doc_with_markdown` – replace the main note content with markdown content
426
+ - `list_children` – list the direct child docs linked from a document
427
+ - `list_backlinks` – list the parent/reference docs that link to a document
428
+ - `cleanup_orphan_embeds` – remove linked-doc embeds that point to missing docs
429
+ - `find_and_replace` – preview or apply text replacement across a document
391
430
  - `delete_doc` – delete a document (WebSocket)
392
431
 
393
432
  ### Comments
@@ -406,6 +445,40 @@ Endpoints currently available:
406
445
  ### Blob Storage
407
446
  - `upload_blob`, `delete_blob`, `cleanup_blobs`
408
447
 
448
+ ## Filtering Exposed Tools
449
+
450
+ Optional environment variables to narrow the exposed surface.
451
+
452
+ ### Group-level — `AFFINE_DISABLED_GROUPS`
453
+
454
+ | Group name | Tools included |
455
+ |---|---|
456
+ | `workspaces` | `list_workspaces`, `get_workspace`, `create_workspace`, `update_workspace`, `delete_workspace` |
457
+ | `docs` | `list_docs`, `read_doc`, `search_docs`, `create_doc`, `create_doc_from_markdown`, `create_doc_from_template`, `duplicate_doc`, `append_paragraph`, `append_block`, `append_markdown`, `replace_doc_with_markdown`, `delete_doc`, `publish_doc`, `revoke_doc`, `list_tags`, `list_docs_by_tag`, `create_tag`, `add_tag_to_doc`, `remove_tag_from_doc`, `list_workspace_tree`, `get_orphan_docs`, `list_children`, `update_doc_title`, `get_doc_by_title`, `get_docs_by_tag`, `list_backlinks`, `move_doc`, `batch_create_docs`, `cleanup_orphan_embeds`, `find_and_replace`, `add_database_column`, `add_database_row`, `delete_database_row`, `read_database_columns`, `read_database_cells`, `update_database_cell`, `update_database_row` |
458
+ | `comments` | `list_comments`, `create_comment`, `update_comment`, `delete_comment`, `resolve_comment` |
459
+ | `history` | `list_histories` |
460
+ | `organize` | `list_collections`, `get_collection`, `create_collection`, `update_collection`, `delete_collection`, `add_doc_to_collection`, `remove_doc_from_collection`, `list_organize_nodes`, `create_folder`, `rename_folder`, `delete_folder`, `move_organize_node`, `add_organize_link`, `delete_organize_link` |
461
+ | `users` | `current_user`, `sign_in`, `update_profile`, `update_settings` |
462
+ | `access_tokens` | `list_access_tokens`, `generate_access_token`, `revoke_access_token` |
463
+ | `blobs` | `upload_blob`, `delete_blob`, `cleanup_blobs` |
464
+ | `notifications` | `list_notifications`, `read_all_notifications` |
465
+
466
+ ```json
467
+ "env": {
468
+ "AFFINE_DISABLED_GROUPS": "comments,history,blobs,users"
469
+ }
470
+ ```
471
+
472
+ ### Tool-level — `AFFINE_DISABLED_TOOLS`
473
+
474
+ Disables individual tools by exact name (comma-separated).
475
+
476
+ ```json
477
+ "env": {
478
+ "AFFINE_DISABLED_TOOLS": "delete_workspace,delete_doc"
479
+ }
480
+ ```
481
+
409
482
  ## Use Locally (clone)
410
483
 
411
484
  ```bash
@@ -434,7 +507,7 @@ npm run pack:check
434
507
  - For full tool-surface verification, run `npm run test:comprehensive` (self-bootstraps a local Docker AFFiNE stack).
435
508
  - For pre-provisioned environments, use `npm run test:comprehensive:raw`.
436
509
  - For full environment verification, run `npm run test:e2e` (Docker + MCP + 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`.
510
+ - 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:organize`, `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:cli-live`, `npm run test:tool-filtering`, `npm run test:markdown-rich-text-import`, `npm run test:playwright`.
438
511
 
439
512
  ## Troubleshooting
440
513
 
package/dist/cli.js CHANGED
@@ -105,6 +105,29 @@ async function gql(baseUrl, auth, query, variables) {
105
105
  function parseFlag(args, ...flags) {
106
106
  return args.some((arg) => flags.includes(arg));
107
107
  }
108
+ function consumeOption(args, flag) {
109
+ const index = args.indexOf(flag);
110
+ if (index === -1)
111
+ return undefined;
112
+ const value = args[index + 1];
113
+ if (!value || value.startsWith("--")) {
114
+ throw new CliError(`Missing value for '${flag}'.`);
115
+ }
116
+ args.splice(index, 2);
117
+ return value;
118
+ }
119
+ function consumeFlags(args, ...flags) {
120
+ let found = false;
121
+ for (const flag of flags) {
122
+ let index = args.indexOf(flag);
123
+ while (index !== -1) {
124
+ args.splice(index, 1);
125
+ found = true;
126
+ index = args.indexOf(flag);
127
+ }
128
+ }
129
+ return found;
130
+ }
108
131
  function ensureNoUnexpectedArgs(args, command) {
109
132
  if (args.length > 0) {
110
133
  throw new CliError(`Unexpected arguments for '${command}': ${args.join(" ")}`);
@@ -178,6 +201,14 @@ async function resolveCliAuth(baseUrl) {
178
201
  }
179
202
  throw new CliError("No authentication configured. Run 'affine-mcp login' or set AFFINE_API_TOKEN.");
180
203
  }
204
+ async function inspectConnection(baseUrl, auth) {
205
+ const data = await gql(baseUrl, auth, "query { currentUser { name email } workspaces { id } }");
206
+ return {
207
+ userName: data.currentUser.name,
208
+ userEmail: data.currentUser.email,
209
+ workspaceCount: data.workspaces.length,
210
+ };
211
+ }
181
212
  function printHelp(command) {
182
213
  if (command) {
183
214
  const definition = COMMANDS[command];
@@ -208,7 +239,11 @@ function printHelp(command) {
208
239
  console.log(" affine-mcp --version");
209
240
  console.log(" affine-mcp --help");
210
241
  }
211
- async function detectWorkspace(baseUrl, auth) {
242
+ async function detectWorkspace(baseUrl, auth, preferredWorkspaceId) {
243
+ if (preferredWorkspaceId) {
244
+ console.error(`Using workspace override: ${preferredWorkspaceId}`);
245
+ return preferredWorkspaceId;
246
+ }
212
247
  console.error("Detecting workspaces...");
213
248
  try {
214
249
  const data = await gql(baseUrl, auth, `query {
@@ -306,7 +341,13 @@ async function loginWithToken(baseUrl) {
306
341
  const workspaceId = await detectWorkspace(baseUrl, { token });
307
342
  return { token, workspaceId };
308
343
  }
309
- async function login(_args) {
344
+ async function login(args) {
345
+ const parsedArgs = [...args];
346
+ const providedUrl = consumeOption(parsedArgs, "--url");
347
+ const providedToken = consumeOption(parsedArgs, "--token");
348
+ const providedWorkspaceId = consumeOption(parsedArgs, "--workspace-id");
349
+ const force = consumeFlags(parsedArgs, "--force", "-f");
350
+ ensureNoUnexpectedArgs(parsedArgs, "login");
310
351
  console.error("Affine MCP Server — Login\n");
311
352
  const existing = loadConfigFile();
312
353
  if (existing.AFFINE_API_TOKEN) {
@@ -314,24 +355,53 @@ async function login(_args) {
314
355
  console.error(` URL: ${existing.AFFINE_BASE_URL || "(default)"}`);
315
356
  console.error(" Token: (set)");
316
357
  console.error(` Workspace: ${existing.AFFINE_WORKSPACE_ID || "(none)"}\n`);
317
- const overwrite = await ask("Overwrite? [y/N] ");
318
- if (!/^[yY]$/.test(overwrite)) {
319
- console.error("Keeping existing config.");
320
- return;
358
+ if (!force) {
359
+ const overwrite = await ask("Overwrite? [y/N] ");
360
+ if (!/^[yY]$/.test(overwrite)) {
361
+ console.error("Keeping existing config.");
362
+ return;
363
+ }
364
+ console.error("");
365
+ }
366
+ else {
367
+ console.error("Overwriting existing config (--force).\n");
321
368
  }
322
- console.error("");
323
369
  }
324
370
  const defaultUrl = "https://app.affine.pro";
325
- const rawUrl = (await ask(`Affine URL [${defaultUrl}]: `)) || defaultUrl;
371
+ const rawUrl = providedUrl ?? ((await ask(`Affine URL [${defaultUrl}]: `)) || defaultUrl);
326
372
  const baseUrl = validateBaseUrl(rawUrl);
327
- const isSelfHosted = !baseUrl.includes("affine.pro");
328
373
  let result;
329
- if (isSelfHosted) {
330
- const method = await ask("\nAuth method — [1] Email/password (recommended) [2] Paste API token: ");
331
- result = method === "2" ? await loginWithToken(baseUrl) : await loginWithEmail(baseUrl);
374
+ if (providedToken) {
375
+ console.error("Testing provided token...");
376
+ try {
377
+ const info = await inspectConnection(baseUrl, { token: providedToken });
378
+ console.error(`✓ Authenticated as: ${info.userName} <${info.userEmail}>\n`);
379
+ }
380
+ catch (err) {
381
+ throw new CliError(`Authentication failed: ${err.message}`);
382
+ }
383
+ result = {
384
+ token: providedToken,
385
+ workspaceId: await detectWorkspace(baseUrl, { token: providedToken }, providedWorkspaceId),
386
+ };
332
387
  }
333
388
  else {
334
- result = await loginWithToken(baseUrl);
389
+ const isSelfHosted = !baseUrl.includes("affine.pro");
390
+ if (isSelfHosted) {
391
+ const method = await ask("\nAuth method — [1] Email/password (recommended) [2] Paste API token: ");
392
+ const loginResult = method === "2" ? await loginWithToken(baseUrl) : await loginWithEmail(baseUrl);
393
+ result = {
394
+ ...loginResult,
395
+ workspaceId: providedWorkspaceId || loginResult.workspaceId,
396
+ };
397
+ }
398
+ else {
399
+ const loginResult = await loginWithToken(baseUrl);
400
+ result = {
401
+ ...loginResult,
402
+ workspaceId: providedWorkspaceId || loginResult.workspaceId,
403
+ };
404
+ }
335
405
  }
336
406
  writeConfigFile({
337
407
  AFFINE_BASE_URL: baseUrl,
@@ -342,19 +412,32 @@ async function login(_args) {
342
412
  console.error("The MCP server will use these credentials automatically.");
343
413
  }
344
414
  async function status(args) {
345
- ensureNoUnexpectedArgs(args, "status");
415
+ const parsedArgs = [...args];
416
+ const asJson = consumeFlags(parsedArgs, "--json");
417
+ ensureNoUnexpectedArgs(parsedArgs, "status");
346
418
  const config = loadConfigFile();
347
419
  if (!config.AFFINE_API_TOKEN) {
348
420
  throw new CliError("Not logged in. Run: affine-mcp login");
349
421
  }
350
- console.error(`Config: ${CONFIG_FILE}`);
351
- console.error(`URL: ${config.AFFINE_BASE_URL || "(default)"}`);
352
- console.error("Token: (set)");
353
- console.error(`Workspace: ${config.AFFINE_WORKSPACE_ID || "(none)"}\n`);
354
422
  try {
355
- const data = await gql(config.AFFINE_BASE_URL || "https://app.affine.pro", { token: config.AFFINE_API_TOKEN }, "query { currentUser { name email } workspaces { id } }");
356
- console.error(`User: ${data.currentUser.name} <${data.currentUser.email}>`);
357
- console.error(`Workspaces: ${data.workspaces.length}`);
423
+ const inspection = await inspectConnection(config.AFFINE_BASE_URL || "https://app.affine.pro", { token: config.AFFINE_API_TOKEN });
424
+ if (asJson) {
425
+ console.log(JSON.stringify({
426
+ configFile: CONFIG_FILE,
427
+ baseUrl: config.AFFINE_BASE_URL || "https://app.affine.pro",
428
+ workspaceId: config.AFFINE_WORKSPACE_ID || null,
429
+ userName: inspection.userName,
430
+ userEmail: inspection.userEmail,
431
+ workspaceCount: inspection.workspaceCount,
432
+ }, null, 2));
433
+ return;
434
+ }
435
+ console.error(`Config: ${CONFIG_FILE}`);
436
+ console.error(`URL: ${config.AFFINE_BASE_URL || "(default)"}`);
437
+ console.error("Token: (set)");
438
+ console.error(`Workspace: ${config.AFFINE_WORKSPACE_ID || "(none)"}\n`);
439
+ console.error(`User: ${inspection.userName} <${inspection.userEmail}>`);
440
+ console.error(`Workspaces: ${inspection.workspaceCount}`);
358
441
  }
359
442
  catch (err) {
360
443
  throw new CliError(`Connection failed: ${err.message}`);
@@ -375,11 +458,9 @@ function configPath(args) {
375
458
  console.log(CONFIG_FILE);
376
459
  }
377
460
  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
- }
461
+ const parsedArgs = [...args];
462
+ const asJson = consumeFlags(parsedArgs, "--json");
463
+ ensureNoUnexpectedArgs(parsedArgs, "show-config");
383
464
  const summary = buildEffectiveConfigSummary();
384
465
  if (asJson) {
385
466
  console.log(JSON.stringify(summary, null, 2));
@@ -405,11 +486,9 @@ function showConfig(args) {
405
486
  console.log(`OAuth scopes: ${summary.oauthScopes.join(", ")} (${summary.sources.oauthScopes})`);
406
487
  }
407
488
  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
- }
489
+ const parsedArgs = [...args];
490
+ const asJson = consumeFlags(parsedArgs, "--json");
491
+ ensureNoUnexpectedArgs(parsedArgs, "doctor");
413
492
  const summary = buildEffectiveConfigSummary();
414
493
  const checks = [];
415
494
  checks.push({
@@ -447,11 +526,11 @@ async function doctor(args) {
447
526
  clearTimeout(healthTimer);
448
527
  }
449
528
  try {
450
- const data = await gql(summary.baseUrl, auth, "query { currentUser { name email } workspaces { id } }");
529
+ const data = await inspectConnection(summary.baseUrl, auth);
451
530
  checks.push({
452
531
  name: "graphql-auth",
453
532
  ok: true,
454
- detail: `${data.currentUser.email} (${data.workspaces.length} workspace(s))`,
533
+ detail: `${data.userEmail} (${data.workspaceCount} workspace(s))`,
455
534
  });
456
535
  }
457
536
  catch (err) {
@@ -522,16 +601,39 @@ function getSnippetEnv() {
522
601
  return env;
523
602
  }
524
603
  function snippet(args) {
525
- const target = args[0];
604
+ const parsedArgs = [...args];
605
+ const includeEnv = consumeFlags(parsedArgs, "--env");
606
+ const target = parsedArgs[0];
526
607
  if (!target) {
527
608
  throw new CliError("Usage: affine-mcp snippet <claude|cursor|codex> [--env]");
528
609
  }
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
- }
610
+ ensureNoUnexpectedArgs(parsedArgs.slice(1), "snippet");
534
611
  const env = includeEnv ? getSnippetEnv() : undefined;
612
+ if (target === "all") {
613
+ const payload = {
614
+ claude: {
615
+ mcpServers: {
616
+ affine: {
617
+ command: "affine-mcp",
618
+ ...(env && Object.keys(env).length > 0 ? { env } : {}),
619
+ },
620
+ },
621
+ },
622
+ cursor: {
623
+ mcpServers: {
624
+ affine: {
625
+ command: "affine-mcp",
626
+ ...(env && Object.keys(env).length > 0 ? { env } : {}),
627
+ },
628
+ },
629
+ },
630
+ codex: env && Object.keys(env).length > 0
631
+ ? `codex mcp add affine ${Object.entries(env).map(([key, value]) => `--env ${key}=${JSON.stringify(value)}`).join(" ")} -- affine-mcp`
632
+ : "codex mcp add affine -- affine-mcp",
633
+ };
634
+ console.log(JSON.stringify(payload, null, 2));
635
+ return;
636
+ }
535
637
  if (target === "claude" || target === "cursor") {
536
638
  const payload = {
537
639
  mcpServers: {
@@ -555,7 +657,7 @@ function snippet(args) {
555
657
  console.log(`codex mcp add affine ${envArgs} -- affine-mcp`);
556
658
  return;
557
659
  }
558
- throw new CliError(`Unknown snippet target '${target}'. Expected claude, cursor, or codex.`);
660
+ throw new CliError(`Unknown snippet target '${target}'. Expected claude, cursor, codex, or all.`);
559
661
  }
560
662
  function help(args) {
561
663
  if (args.length > 1) {
@@ -571,12 +673,12 @@ const COMMANDS = {
571
673
  },
572
674
  login: {
573
675
  summary: "Interactive login and config bootstrap",
574
- usage: "affine-mcp login",
676
+ usage: "affine-mcp login [--url <url>] [--token <token>] [--workspace-id <id>] [--force]",
575
677
  handler: login,
576
678
  },
577
679
  status: {
578
680
  summary: "Test the saved config and print current user info",
579
- usage: "affine-mcp status",
681
+ usage: "affine-mcp status [--json]",
580
682
  handler: status,
581
683
  },
582
684
  logout: {
@@ -601,7 +703,7 @@ const COMMANDS = {
601
703
  },
602
704
  snippet: {
603
705
  summary: "Print ready-to-paste Claude/Cursor/Codex snippets",
604
- usage: "affine-mcp snippet <claude|cursor|codex> [--env]",
706
+ usage: "affine-mcp snippet <claude|cursor|codex|all> [--env]",
605
707
  handler: snippet,
606
708
  },
607
709
  };
package/dist/index.js CHANGED
@@ -13,8 +13,11 @@ import { registerBlobTools } from "./tools/blobStorage.js";
13
13
  import { registerNotificationTools } from "./tools/notifications.js";
14
14
  import { loginWithPassword } from "./auth.js";
15
15
  import { registerAuthTools } from "./tools/auth.js";
16
+ import { registerOrganizeTools } from "./tools/organize.js";
16
17
  import { runCli } from "./cli.js";
17
18
  import { startHttpMcpServer } from "./sse.js";
19
+ import { existsSync } from "fs";
20
+ import { CONFIG_FILE } from "./config.js";
18
21
  // CLI commands: affine-mcp login|status|logout|version
19
22
  const rawArgs = process.argv.slice(2);
20
23
  const cliArgs = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs;
@@ -40,9 +43,22 @@ if (subcommand) {
40
43
  const config = loadConfig();
41
44
  const transportMode = (process.env.MCP_TRANSPORT || "stdio").toLowerCase();
42
45
  const useHttpTransport = transportMode === "sse" || transportMode === "http" || transportMode === "streamable";
46
+ // ---------------------------------------------------------------------------
47
+ // Tool filtering — parsed once at module load (not per-session in HTTP mode)
48
+ // ---------------------------------------------------------------------------
49
+ const KNOWN_GROUPS = new Set([
50
+ "workspaces", "docs", "comments", "history", "organize",
51
+ "users", "access_tokens", "blobs", "notifications",
52
+ ]);
53
+ const DISABLED_GROUPS = new Set((process.env.AFFINE_DISABLED_GROUPS || "")
54
+ .split(",")
55
+ .map((s) => s.trim().toLowerCase())
56
+ .filter(Boolean));
57
+ const DISABLED_TOOLS = new Set((process.env.AFFINE_DISABLED_TOOLS || "")
58
+ .split(",")
59
+ .map((s) => s.trim())
60
+ .filter(Boolean));
43
61
  // Startup diagnostics (visible in Claude Code MCP server logs via stderr)
44
- import { existsSync } from "fs";
45
- import { CONFIG_FILE } from "./config.js";
46
62
  console.error(`[affine-mcp] Config: ${CONFIG_FILE} (${existsSync(CONFIG_FILE) ? 'found' : 'missing'})`);
47
63
  console.error(`[affine-mcp] Endpoint: ${config.baseUrl}${config.graphqlPath}`);
48
64
  const hasAuth = !!(config.apiToken || config.cookie || (config.email && config.password));
@@ -54,6 +70,13 @@ if (hasAuth && config.baseUrl.startsWith("http://")
54
70
  console.error("WARNING: Credentials configured over plain HTTP. Use HTTPS for remote servers.");
55
71
  }
56
72
  console.error(`[affine-mcp] Workspace: ${config.defaultWorkspaceId ? 'set' : '(none)'}`);
73
+ // Warn about unknown group names (likely typos) before they silently do nothing
74
+ for (const g of DISABLED_GROUPS) {
75
+ if (!KNOWN_GROUPS.has(g)) {
76
+ console.error(`[affine-mcp] WARNING: Unknown group "${g}" in AFFINE_DISABLED_GROUPS — ` +
77
+ `valid groups: ${[...KNOWN_GROUPS].join(", ")}`);
78
+ }
79
+ }
57
80
  if (config.authMode === "oauth" && !useHttpTransport) {
58
81
  throw new Error("AFFINE_MCP_AUTH_MODE=oauth requires MCP_TRANSPORT=http (or streamable/sse).");
59
82
  }
@@ -132,18 +155,52 @@ async function buildServer() {
132
155
  console.error("WARNING: No authentication configured. Some operations may fail.");
133
156
  console.error("Set AFFINE_API_TOKEN or run: affine-mcp login");
134
157
  }
135
- registerWorkspaceTools(server, gql);
136
- registerDocTools(server, gql, { workspaceId: config.defaultWorkspaceId });
137
- registerCommentTools(server, gql, { workspaceId: config.defaultWorkspaceId });
138
- registerHistoryTools(server, gql, { workspaceId: config.defaultWorkspaceId });
139
- registerUserTools(server, gql);
140
- registerUserCRUDTools(server, gql);
141
- registerAccessTokenTools(server, gql);
142
- registerBlobTools(server, gql);
143
- registerNotificationTools(server, gql);
144
- if (config.authMode !== "oauth") {
145
- registerAuthTools(server, gql, config.baseUrl);
158
+ // ---------------------------------------------------------------------------
159
+ // Per-tool blacklist: patch registerTool on this server instance so individual
160
+ // tools in AFFINE_DISABLED_TOOLS are silently skipped during registration.
161
+ // All tool files use server.registerTool exclusively no need to patch server.tool.
162
+ // ---------------------------------------------------------------------------
163
+ if (DISABLED_TOOLS.size > 0) {
164
+ const originalRegisterTool = server.registerTool?.bind(server);
165
+ if (typeof originalRegisterTool !== "function") {
166
+ console.error("[affine-mcp] WARNING: server.registerTool not found — " +
167
+ "AFFINE_DISABLED_TOOLS will have no effect. " +
168
+ "The MCP SDK API may have changed.");
169
+ }
170
+ else {
171
+ server.registerTool = (name, options, handler) => {
172
+ if (DISABLED_TOOLS.has(name))
173
+ return;
174
+ return originalRegisterTool(name, options, handler);
175
+ };
176
+ }
177
+ }
178
+ // Log filters directly from environment variables
179
+ console.error(`[affine-mcp] Disabled groups: ${process.env.AFFINE_DISABLED_GROUPS || "(none)"}`);
180
+ console.error(`[affine-mcp] Disabled tools: ${process.env.AFFINE_DISABLED_TOOLS || "(none)"}`);
181
+ if (!DISABLED_GROUPS.has("workspaces"))
182
+ registerWorkspaceTools(server, gql);
183
+ if (!DISABLED_GROUPS.has("docs"))
184
+ registerDocTools(server, gql, { workspaceId: config.defaultWorkspaceId });
185
+ if (!DISABLED_GROUPS.has("comments"))
186
+ registerCommentTools(server, gql, { workspaceId: config.defaultWorkspaceId });
187
+ if (!DISABLED_GROUPS.has("history"))
188
+ registerHistoryTools(server, gql, { workspaceId: config.defaultWorkspaceId });
189
+ if (!DISABLED_GROUPS.has("organize"))
190
+ registerOrganizeTools(server, gql, { workspaceId: config.defaultWorkspaceId });
191
+ if (!DISABLED_GROUPS.has("users")) {
192
+ registerUserTools(server, gql);
193
+ registerUserCRUDTools(server, gql);
194
+ if (config.authMode !== "oauth") {
195
+ registerAuthTools(server, gql, config.baseUrl);
196
+ }
146
197
  }
198
+ if (!DISABLED_GROUPS.has("access_tokens"))
199
+ registerAccessTokenTools(server, gql);
200
+ if (!DISABLED_GROUPS.has("blobs"))
201
+ registerBlobTools(server, gql);
202
+ if (!DISABLED_GROUPS.has("notifications"))
203
+ registerNotificationTools(server, gql);
147
204
  return server;
148
205
  }
149
206
  async function start() {