affine-mcp-server 2.0.0 → 2.1.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 for AFFiNE. It exposes AFFiNE workspaces and documents to AI assistants over stdio (default) or HTTP (`/mcp`) and supports both AFFiNE Cloud and self-hosted deployments.
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.0.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.1.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)
@@ -38,7 +38,7 @@ Highlights:
38
38
  - Supports AFFiNE Cloud and self-hosted AFFiNE instances
39
39
  - Supports stdio and HTTP transports
40
40
  - Supports token, cookie, and email/password authentication
41
- - Exposes 84 canonical MCP tools backed by AFFiNE GraphQL and WebSocket APIs
41
+ - Exposes 85 canonical MCP tools backed by AFFiNE GraphQL and WebSocket APIs
42
42
  - Includes semantic page composition, native template instantiation, database intent composition, capability and fidelity reporting, and workspace blueprint helpers
43
43
  - Includes Docker images, health probes, and end-to-end test coverage
44
44
 
@@ -48,7 +48,7 @@ Scope boundaries:
48
48
  - Browser-local workspaces stored only in local storage are not available through AFFiNE server APIs
49
49
  - AFFiNE Cloud requires API-token-based access for MCP usage; programmatic email/password sign-in is blocked by Cloudflare
50
50
 
51
- > New in v2.0.0: Added native edgeless canvas tools and shipped a slimmer 84-tool public surface with least-privilege profiles for read-only, core, and authoring deployments.
51
+ > New in v2.1.0: Added exact-title document lookup, folder placement for `create_doc`, and trusted npm publishing.
52
52
 
53
53
  ## Choose Your Path
54
54
  | Goal | Start here |
@@ -33,6 +33,7 @@ const ALL_TOOLS = [
33
33
  "delete_workspace",
34
34
  "export_doc_markdown",
35
35
  "export_with_fidelity_report",
36
+ "find_doc_by_title",
36
37
  "generate_access_token",
37
38
  "get_capabilities",
38
39
  "get_collection",
@@ -119,6 +120,7 @@ const TOOL_GROUPS = {
119
120
  delete_workspace: ["workspaces", "workspaces.write", "admin", "destructive", "write"],
120
121
  export_doc_markdown: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
121
122
  export_with_fidelity_report: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
123
+ find_doc_by_title: ["docs", "docs.read", "read"],
122
124
  generate_access_token: ["access_tokens", "access_tokens.write", "admin", "write"],
123
125
  get_capabilities: ["docs", "docs.read", "read"],
124
126
  get_collection: ["organize", "organize.collections", "organize.read", "read"],
@@ -175,6 +177,7 @@ const READ_ONLY_TOOLS = new Set([
175
177
  "current_user",
176
178
  "export_doc_markdown",
177
179
  "export_with_fidelity_report",
180
+ "find_doc_by_title",
178
181
  "get_capabilities",
179
182
  "get_collection",
180
183
  "get_doc",
@@ -211,6 +214,7 @@ const CORE_TOOLS = new Set([
211
214
  "create_doc_from_markdown",
212
215
  "current_user",
213
216
  "export_doc_markdown",
217
+ "find_doc_by_title",
214
218
  "get_capabilities",
215
219
  "get_doc",
216
220
  "get_workspace",
@@ -5,6 +5,7 @@ import { wsUrlFromGraphQLEndpoint, connectWorkspaceSocket, joinWorkspace, loadDo
5
5
  import * as Y from "yjs";
6
6
  import { parseMarkdownToOperations } from "../markdown/parse.js";
7
7
  import { renderBlocksToMarkdown } from "../markdown/render.js";
8
+ import { addOrganizeLinkToFolder } from "./organize.js";
8
9
  import { DEFAULT_NOTE_XYWH, DEFAULT_STACK_GAP_HORIZONTAL, DEFAULT_STACK_GAP_VERTICAL, SIDE_TO_NORMALIZED_POSITION, encloseBounds, estimateConnectorLabelXYWH, estimateNoteHeightForMarkdown, formatXywhString, parseXywhString, pickConnectorSides, pickFurthestInDirection, sortByFractionalIndex, stackRelativeTo, } from "../edgeless/layout.js";
9
10
  const WorkspaceId = z.string().min(1, "workspaceId required");
10
11
  const DocId = z.string().min(1, "docId required");
@@ -3387,6 +3388,88 @@ export function registerDocTools(server, gql, defaults) {
3387
3388
  sortDirection: z.enum(["asc", "desc"]).optional().describe("Sort direction for updatedAt sorting (default: desc)."),
3388
3389
  },
3389
3390
  }, searchDocsHandler);
3391
+ const findDocByTitleHandler = async (parsed) => {
3392
+ const workspaceId = parsed.workspaceId || defaults.workspaceId;
3393
+ if (!workspaceId) {
3394
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
3395
+ }
3396
+ const title = parsed.title;
3397
+ if (!title || title.length === 0) {
3398
+ throw new Error("title is required and must be non-empty.");
3399
+ }
3400
+ const limit = parsed.limit ?? 50;
3401
+ const caseInsensitive = parsed.caseInsensitive ?? false;
3402
+ const target = caseInsensitive ? title.toLocaleLowerCase() : title;
3403
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3404
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3405
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3406
+ try {
3407
+ await joinWorkspace(socket, workspaceId);
3408
+ const snapshot = await loadDoc(socket, workspaceId, workspaceId);
3409
+ if (!snapshot.missing) {
3410
+ return text({
3411
+ query: title,
3412
+ caseInsensitive,
3413
+ matches: [],
3414
+ workspaceDocCount: 0,
3415
+ truncated: false,
3416
+ });
3417
+ }
3418
+ const wsDoc = new Y.Doc();
3419
+ Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
3420
+ const meta = wsDoc.getMap("meta");
3421
+ const pages = getWorkspacePageEntries(meta);
3422
+ const matches = [];
3423
+ let truncated = false;
3424
+ for (const page of pages) {
3425
+ const pageTitle = page.title ?? "";
3426
+ const candidate = caseInsensitive ? pageTitle.toLocaleLowerCase() : pageTitle;
3427
+ if (candidate !== target)
3428
+ continue;
3429
+ if (matches.length >= limit) {
3430
+ // We hit `limit` on a previous iteration and now found another match —
3431
+ // there are genuinely more matches than the cap.
3432
+ truncated = true;
3433
+ break;
3434
+ }
3435
+ // A doc that has never been edited after creation has no
3436
+ // `updatedDate` in workspace meta — fall back to `createDate` so
3437
+ // `updatedAt` is always populated, consistent with `search_docs`.
3438
+ const updatedTimestamp = page.updatedDate ?? page.createDate;
3439
+ matches.push({
3440
+ id: page.id,
3441
+ title: pageTitle,
3442
+ createdAt: page.createDate ? new Date(page.createDate).toISOString() : null,
3443
+ updatedAt: updatedTimestamp ? new Date(updatedTimestamp).toISOString() : null,
3444
+ });
3445
+ }
3446
+ return text({
3447
+ query: title,
3448
+ caseInsensitive,
3449
+ matches,
3450
+ workspaceDocCount: pages.length,
3451
+ truncated,
3452
+ });
3453
+ }
3454
+ finally {
3455
+ socket.disconnect();
3456
+ }
3457
+ };
3458
+ server.registerTool("find_doc_by_title", {
3459
+ title: "Find Doc by Title",
3460
+ description: "Resolve docs by exact title. Returns ALL matches up to `limit` (callers handle ambiguity). " +
3461
+ "Case-sensitive by default; pass `caseInsensitive: true` to fold case. " +
3462
+ "Reads workspace metadata — fast, no per-doc fetch. " +
3463
+ "Unlike `search_docs` (which is always case-insensitive and capped at limit 20), this tool defaults to case-sensitive matching and returns up to `limit` matches (default 50, max 200). " +
3464
+ "Prefer this over `search_docs` when you know the exact title and want every match. " +
3465
+ "Returns: { query, caseInsensitive, matches: [{ id, title, createdAt, updatedAt }], workspaceDocCount, truncated }.",
3466
+ inputSchema: {
3467
+ workspaceId: z.string().optional().describe("Workspace ID (optional if AFFINE_WORKSPACE_ID is set)."),
3468
+ title: z.string().min(1).describe("The exact title to match."),
3469
+ caseInsensitive: z.boolean().optional().describe("If true, fold case for comparison (default: false)."),
3470
+ limit: z.number().int().positive().max(200).optional().describe("Max matches to return (default: 50)."),
3471
+ },
3472
+ }, findDocByTitleHandler);
3390
3473
  server.registerTool("list_tags", {
3391
3474
  title: "List Tags",
3392
3475
  description: "List all tags in a workspace and the number of docs attached to each tag.",
@@ -4033,23 +4116,51 @@ export function registerDocTools(server, gql, defaults) {
4033
4116
  parentDocId: parsed.parentDocId,
4034
4117
  context: "create_doc",
4035
4118
  });
4119
+ const warnings = mergeWarnings(created.warnings ?? [], placement.warnings);
4120
+ let linkedFolderId = null;
4121
+ let folderNodeId = null;
4122
+ if (parsed.folderId) {
4123
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4124
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4125
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4126
+ try {
4127
+ await joinWorkspace(socket, created.workspaceId);
4128
+ const link = await addOrganizeLinkToFolder(socket, created.workspaceId, {
4129
+ folderId: parsed.folderId,
4130
+ type: "doc",
4131
+ targetId: created.docId,
4132
+ });
4133
+ linkedFolderId = link.parentId;
4134
+ folderNodeId = link.id;
4135
+ }
4136
+ catch (err) {
4137
+ warnings.push(`Doc created but could not be placed in folder "${parsed.folderId}": ${err?.message ?? "unknown error"}`);
4138
+ }
4139
+ finally {
4140
+ socket.disconnect();
4141
+ }
4142
+ }
4036
4143
  return receipt("doc.create", {
4037
4144
  workspaceId: created.workspaceId,
4038
4145
  docId: created.docId,
4039
4146
  title: created.title,
4040
4147
  parentDocId: placement.parentDocId,
4041
4148
  linkedToParent: placement.linkedToParent,
4042
- warnings: mergeWarnings(created.warnings ?? [], placement.warnings),
4149
+ folderId: linkedFolderId,
4150
+ folderLinked: folderNodeId !== null,
4151
+ folderNodeId,
4152
+ warnings,
4043
4153
  });
4044
4154
  };
4045
4155
  server.registerTool('create_doc', {
4046
4156
  title: 'Create Document',
4047
- description: 'Create a new AFFiNE document with optional content. If parentDocId is provided, the new doc is linked into the sidebar tree immediately.',
4157
+ description: 'Create a new AFFiNE document with optional content. If parentDocId is provided, the new doc is linked into the sidebar tree immediately. If folderId is provided, the doc is placed inside that folder in the sidebar.',
4048
4158
  inputSchema: {
4049
4159
  workspaceId: z.string().optional(),
4050
4160
  title: z.string().optional(),
4051
4161
  content: z.string().optional(),
4052
4162
  parentDocId: z.string().optional().describe("Optional parent doc to link the new doc under in the sidebar."),
4163
+ folderId: z.string().optional().describe("Optional folder ID to place the doc in. Use list_organize_nodes to find folder IDs."),
4053
4164
  },
4054
4165
  }, createDocHandler);
4055
4166
  const semanticSectionSchema = z.object({
@@ -453,6 +453,43 @@ function nextOrganizeIndex(nodes, parentId) {
453
453
  const last = siblings.at(-1);
454
454
  return generateFractionalIndexingKeyBetween(last?.index ?? null, null);
455
455
  }
456
+ async function loadFoldersDoc(socket, workspaceId) {
457
+ const docId = specialWorkspaceDbDocId(workspaceId, "folders");
458
+ const snapshot = await loadDoc(socket, workspaceId, docId);
459
+ const doc = new Y.Doc();
460
+ if (snapshot.missing) {
461
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
462
+ }
463
+ return { docId, doc, snapshot };
464
+ }
465
+ async function saveFoldersDoc(socket, workspaceId, docId, doc) {
466
+ const update = Y.encodeStateAsUpdate(doc);
467
+ await pushDocUpdate(socket, workspaceId, docId, Buffer.from(update).toString("base64"));
468
+ }
469
+ export async function addOrganizeLinkToFolder(socket, workspaceId, { folderId, type, targetId, index, }) {
470
+ const { docId, doc } = await loadFoldersDoc(socket, workspaceId);
471
+ const nodes = readOrganizeNodes(doc);
472
+ const nodeMap = organizeNodeMap(nodes);
473
+ ensureNodeIsFolder(nodeMap, folderId);
474
+ const linkId = generateId();
475
+ const nextIndex = index ?? nextOrganizeIndex(nodes, folderId);
476
+ const record = ensureRecord(doc, linkId);
477
+ record.set("id", linkId);
478
+ record.set("type", type);
479
+ record.set("data", targetId);
480
+ record.set("parentId", folderId);
481
+ record.set("index", nextIndex);
482
+ record.delete("$$DELETED");
483
+ await saveFoldersDoc(socket, workspaceId, docId, doc);
484
+ return {
485
+ id: linkId,
486
+ parentId: folderId,
487
+ type,
488
+ data: targetId,
489
+ index: nextIndex,
490
+ storageDocId: docId,
491
+ };
492
+ }
456
493
  export function registerOrganizeTools(server, gql, defaults) {
457
494
  async function getSocketContext() {
458
495
  const endpoint = gql.endpoint;
@@ -474,19 +511,6 @@ export function registerOrganizeTools(server, gql, defaults) {
474
511
  const update = Y.encodeStateAsUpdate(doc);
475
512
  await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(update).toString("base64"));
476
513
  }
477
- async function loadFoldersDoc(socket, workspaceId) {
478
- const docId = specialWorkspaceDbDocId(workspaceId, "folders");
479
- const snapshot = await loadDoc(socket, workspaceId, docId);
480
- const doc = new Y.Doc();
481
- if (snapshot.missing) {
482
- Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
483
- }
484
- return { docId, doc, snapshot };
485
- }
486
- async function saveFoldersDoc(socket, workspaceId, docId, doc) {
487
- const update = Y.encodeStateAsUpdate(doc);
488
- await pushDocUpdate(socket, workspaceId, docId, Buffer.from(update).toString("base64"));
489
- }
490
514
  function sleep(ms) {
491
515
  return new Promise(resolve => setTimeout(resolve, ms));
492
516
  }
@@ -1068,28 +1092,13 @@ export function registerOrganizeTools(server, gql, defaults) {
1068
1092
  const { socket } = await getSocketContext();
1069
1093
  try {
1070
1094
  await joinWorkspace(socket, resolvedWorkspaceId);
1071
- const { docId, doc } = await loadFoldersDoc(socket, resolvedWorkspaceId);
1072
- const nodes = readOrganizeNodes(doc);
1073
- const nodeMap = organizeNodeMap(nodes);
1074
- ensureNodeIsFolder(nodeMap, folderId);
1075
- const linkId = generateId();
1076
- const nextIndex = index ?? nextOrganizeIndex(nodes, folderId);
1077
- const record = ensureRecord(doc, linkId);
1078
- record.set("id", linkId);
1079
- record.set("type", type);
1080
- record.set("data", targetId);
1081
- record.set("parentId", folderId);
1082
- record.set("index", nextIndex);
1083
- record.delete("$$DELETED");
1084
- await saveFoldersDoc(socket, resolvedWorkspaceId, docId, doc);
1085
- return text({
1086
- id: linkId,
1087
- parentId: folderId,
1095
+ const link = await addOrganizeLinkToFolder(socket, resolvedWorkspaceId, {
1096
+ folderId,
1088
1097
  type,
1089
- data: targetId,
1090
- index: nextIndex,
1091
- storageDocId: docId,
1098
+ targetId,
1099
+ index,
1092
1100
  });
1101
+ return text(link);
1093
1102
  }
1094
1103
  finally {
1095
1104
  socket.disconnect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "affine-mcp-server",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Model Context Protocol server for AFFiNE - enables AI assistants to interact with AFFiNE workspaces, documents, and collaboration features.",
@@ -8,12 +8,12 @@
8
8
  "license": "MIT",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "git+https://github.com/dawncr0w/affine-mcp-server.git"
11
+ "url": "git+https://github.com/DAWNCR0W/affine-mcp-server.git"
12
12
  },
13
13
  "bugs": {
14
- "url": "https://github.com/dawncr0w/affine-mcp-server/issues"
14
+ "url": "https://github.com/DAWNCR0W/affine-mcp-server/issues"
15
15
  },
16
- "homepage": "https://github.com/dawncr0w/affine-mcp-server#readme",
16
+ "homepage": "https://github.com/DAWNCR0W/affine-mcp-server#readme",
17
17
  "keywords": [
18
18
  "mcp",
19
19
  "affine",
@@ -47,6 +47,7 @@
47
47
  "test:db-schema": "node tests/test-database-schema.mjs",
48
48
  "test:data-view": "node tests/test-data-view.mjs",
49
49
  "test:doc-discovery": "node tests/test-doc-discovery.mjs",
50
+ "test:find-doc-by-title": "node tests/test-find-doc-by-title.mjs",
50
51
  "test:create-placement": "node tests/test-create-placement.mjs",
51
52
  "test:surface-elements": "node tests/test-surface-elements.mjs",
52
53
  "test:surface-element-gating": "node scripts/verify-surface-element-gating.mjs",
@@ -60,6 +61,7 @@
60
61
  "test:http-bearer": "node tests/test-http-bearer.mjs",
61
62
  "test:oauth-http": "node tests/test-oauth-http.mjs",
62
63
  "test:organize": "node tests/test-organize-tools.mjs",
64
+ "test:create-doc-folder-placement": "node tests/test-create-doc-folder-placement.mjs",
63
65
  "test:supporting-tools": "node tests/test-supporting-tools.mjs",
64
66
  "test:tag-visibility": "node tests/test-tag-visibility.mjs",
65
67
  "test:markdown-roundtrip": "node tests/test-markdown-roundtrip.mjs",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0",
2
+ "version": "2.1.0",
3
3
  "tools": [
4
4
  "add_database_column",
5
5
  "add_database_row",
@@ -35,6 +35,7 @@
35
35
  "delete_workspace",
36
36
  "export_doc_markdown",
37
37
  "export_with_fidelity_report",
38
+ "find_doc_by_title",
38
39
  "generate_access_token",
39
40
  "get_capabilities",
40
41
  "get_collection",