affine-mcp-server 2.0.0 → 2.2.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.2.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 90 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.2.0: Added document custom-property tools, `read_doc` LinkedPage reference IDs, and table ordering fixes.
52
52
 
53
53
  ## Choose Your Path
54
54
  | Goal | Start here |
@@ -170,7 +170,7 @@ Domains:
170
170
 
171
171
  - Workspace: create, inspect, update, delete, and traverse workspaces
172
172
  - Organization: collections, collection-rule sync, workspace blueprints, and experimental organize or folder helpers
173
- - Documents: search, read, create, publish, move, tag, import/export, semantic composition, template inspection and native instantiation, capability and fidelity reporting, and block-level mutation
173
+ - Documents: search, read, create, publish, move, tag, custom properties, import/export, semantic composition, template inspection and native instantiation, capability and fidelity reporting, and block-level mutation
174
174
  - Databases: create columns, add rows, update rows, inspect schema, and compose database structures from intent
175
175
  - Comments: list, create, update, delete, and resolve
176
176
  - History: version history listing
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@ import { registerNotificationTools } from "./tools/notifications.js";
14
14
  import { loginWithPassword } from "./auth.js";
15
15
  import { registerAuthTools } from "./tools/auth.js";
16
16
  import { registerOrganizeTools } from "./tools/organize.js";
17
+ import { registerPropertyTools } from "./tools/properties.js";
17
18
  import { runCli } from "./cli.js";
18
19
  import { startHttpMcpServer } from "./sse.js";
19
20
  import { existsSync } from "fs";
@@ -165,6 +166,7 @@ async function buildServer() {
165
166
  registerCommentTools(server, gql, { workspaceId: config.defaultWorkspaceId });
166
167
  registerHistoryTools(server, gql, { workspaceId: config.defaultWorkspaceId });
167
168
  registerOrganizeTools(server, gql, { workspaceId: config.defaultWorkspaceId });
169
+ registerPropertyTools(server, gql, { workspaceId: config.defaultWorkspaceId });
168
170
  registerUserTools(server, gql);
169
171
  registerUserCRUDTools(server, gql);
170
172
  if (config.authMode !== "oauth") {
@@ -10,9 +10,11 @@ const ALL_TOOLS = [
10
10
  "append_markdown",
11
11
  "append_semantic_section",
12
12
  "cleanup_blobs",
13
+ "clear_doc_property",
13
14
  "compose_database_from_intent",
14
15
  "create_collection",
15
16
  "create_comment",
17
+ "create_custom_property",
16
18
  "create_doc",
17
19
  "create_doc_from_markdown",
18
20
  "create_folder",
@@ -25,6 +27,7 @@ const ALL_TOOLS = [
25
27
  "delete_block",
26
28
  "delete_collection",
27
29
  "delete_comment",
30
+ "delete_custom_property",
28
31
  "delete_database_row",
29
32
  "delete_doc",
30
33
  "delete_folder",
@@ -33,6 +36,7 @@ const ALL_TOOLS = [
33
36
  "delete_workspace",
34
37
  "export_doc_markdown",
35
38
  "export_with_fidelity_report",
39
+ "find_doc_by_title",
36
40
  "generate_access_token",
37
41
  "get_capabilities",
38
42
  "get_collection",
@@ -46,6 +50,7 @@ const ALL_TOOLS = [
46
50
  "list_children",
47
51
  "list_collections",
48
52
  "list_comments",
53
+ "list_doc_properties",
49
54
  "list_docs",
50
55
  "list_docs_by_tag",
51
56
  "list_histories",
@@ -70,6 +75,7 @@ const ALL_TOOLS = [
70
75
  "revoke_access_token",
71
76
  "revoke_doc",
72
77
  "search_docs",
78
+ "set_doc_property",
73
79
  "sign_in",
74
80
  "update_collection",
75
81
  "update_collection_rules",
@@ -96,9 +102,11 @@ const TOOL_GROUPS = {
96
102
  append_markdown: ["docs", "docs.markdown", "docs.write", "write"],
97
103
  append_semantic_section: ["docs", "docs.semantic", "docs.write", "write"],
98
104
  cleanup_blobs: ["blobs", "blobs.write", "cleanup", "destructive", "write"],
105
+ clear_doc_property: ["docs", "docs.properties", "docs.write", "write"],
99
106
  compose_database_from_intent: ["docs", "docs.database", "docs.intent", "docs.write", "write"],
100
107
  create_collection: ["organize", "organize.collections", "organize.write", "write"],
101
108
  create_comment: ["comments", "comments.write", "write"],
109
+ create_custom_property: ["docs", "docs.properties", "docs.write", "write"],
102
110
  create_doc: ["docs", "docs.write", "write"],
103
111
  create_doc_from_markdown: ["docs", "docs.markdown", "docs.write", "write"],
104
112
  create_folder: ["organize", "organize.folders", "organize.write", "experimental", "write"],
@@ -111,6 +119,7 @@ const TOOL_GROUPS = {
111
119
  delete_block: ["docs", "docs.edgeless", "docs.write", "destructive", "write"],
112
120
  delete_collection: ["organize", "organize.collections", "organize.write", "destructive", "write"],
113
121
  delete_comment: ["comments", "comments.write", "destructive", "write"],
122
+ delete_custom_property: ["docs", "docs.properties", "docs.write", "destructive", "write"],
114
123
  delete_database_row: ["docs", "docs.database", "docs.write", "destructive", "write"],
115
124
  delete_doc: ["docs", "docs.write", "destructive", "write"],
116
125
  delete_folder: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
@@ -119,6 +128,7 @@ const TOOL_GROUPS = {
119
128
  delete_workspace: ["workspaces", "workspaces.write", "admin", "destructive", "write"],
120
129
  export_doc_markdown: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
121
130
  export_with_fidelity_report: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
131
+ find_doc_by_title: ["docs", "docs.read", "read"],
122
132
  generate_access_token: ["access_tokens", "access_tokens.write", "admin", "write"],
123
133
  get_capabilities: ["docs", "docs.read", "read"],
124
134
  get_collection: ["organize", "organize.collections", "organize.read", "read"],
@@ -132,6 +142,7 @@ const TOOL_GROUPS = {
132
142
  list_children: ["docs", "docs.tree", "docs.read", "read"],
133
143
  list_collections: ["organize", "organize.collections", "organize.read", "read"],
134
144
  list_comments: ["comments", "comments.read", "read"],
145
+ list_doc_properties: ["docs", "docs.properties", "docs.read", "read"],
135
146
  list_docs: ["docs", "docs.read", "read"],
136
147
  list_docs_by_tag: ["docs", "docs.tags", "docs.read", "read"],
137
148
  list_histories: ["history", "history.read", "read"],
@@ -156,6 +167,7 @@ const TOOL_GROUPS = {
156
167
  revoke_access_token: ["access_tokens", "access_tokens.write", "admin", "destructive", "write"],
157
168
  revoke_doc: ["docs", "docs.share", "docs.write", "destructive", "write"],
158
169
  search_docs: ["docs", "docs.read", "read"],
170
+ set_doc_property: ["docs", "docs.properties", "docs.write", "write"],
159
171
  sign_in: ["users", "users.auth", "auth", "write"],
160
172
  update_collection: ["organize", "organize.collections", "organize.write", "write"],
161
173
  update_collection_rules: ["organize", "organize.collections", "organize.write", "write"],
@@ -175,6 +187,7 @@ const READ_ONLY_TOOLS = new Set([
175
187
  "current_user",
176
188
  "export_doc_markdown",
177
189
  "export_with_fidelity_report",
190
+ "find_doc_by_title",
178
191
  "get_capabilities",
179
192
  "get_collection",
180
193
  "get_doc",
@@ -186,6 +199,7 @@ const READ_ONLY_TOOLS = new Set([
186
199
  "list_children",
187
200
  "list_collections",
188
201
  "list_comments",
202
+ "list_doc_properties",
189
203
  "list_docs",
190
204
  "list_docs_by_tag",
191
205
  "list_histories",
@@ -211,6 +225,7 @@ const CORE_TOOLS = new Set([
211
225
  "create_doc_from_markdown",
212
226
  "current_user",
213
227
  "export_doc_markdown",
228
+ "find_doc_by_title",
214
229
  "get_capabilities",
215
230
  "get_doc",
216
231
  "get_workspace",
@@ -1,10 +1,11 @@
1
1
  import { z } from "zod";
2
- import { generateKeyBetween } from "fractional-indexing";
2
+ import { generateKeyBetween, generateNKeysBetween } from "fractional-indexing";
3
3
  import { receipt, text } from "../util/mcp.js";
4
4
  import { wsUrlFromGraphQLEndpoint, connectWorkspaceSocket, joinWorkspace, loadDoc, pushDocUpdate, deleteDoc as wsDeleteDoc } from "../ws.js";
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");
@@ -165,22 +166,30 @@ export function registerDocTools(server, gql, defaults) {
165
166
  return makeText(delta);
166
167
  }
167
168
  /**
168
- * Extract a linked-doc page ID from a database row block's prop:text,
169
- * if it contains a LinkedPage reference delta. Returns null otherwise.
169
+ * Extract inline LinkedPage reference IDs from a Y.Text value. AFFiNE stores
170
+ * @-mentions as zero-width text deltas whose page id lives in attributes.
170
171
  */
171
- function readLinkedDocId(rowBlock) {
172
- const propText = rowBlock.get("prop:text");
172
+ function extractLinkedPageRefs(propText) {
173
173
  if (!(propText instanceof Y.Text))
174
- return null;
174
+ return [];
175
175
  const delta = propText.toDelta();
176
176
  if (!Array.isArray(delta))
177
- return null;
177
+ return [];
178
+ const refs = [];
178
179
  for (const d of delta) {
179
- if (d.attributes?.reference?.type === "LinkedPage" && d.attributes.reference.pageId) {
180
- return d.attributes.reference.pageId;
180
+ const reference = d.attributes?.reference;
181
+ if (reference?.type === "LinkedPage" && typeof reference.pageId === "string" && reference.pageId.length > 0) {
182
+ refs.push(reference.pageId);
181
183
  }
182
184
  }
183
- return null;
185
+ return refs;
186
+ }
187
+ /**
188
+ * Extract a linked-doc page ID from a database row block's prop:text,
189
+ * if it contains a LinkedPage reference delta. Returns null otherwise.
190
+ */
191
+ function readLinkedDocId(rowBlock) {
192
+ return extractLinkedPageRefs(rowBlock.get("prop:text"))[0] ?? null;
184
193
  }
185
194
  function asText(value) {
186
195
  if (value instanceof Y.Text)
@@ -1339,16 +1348,23 @@ export function registerDocTools(server, gql, defaults) {
1339
1348
  const rowIds = [];
1340
1349
  const columnIds = [];
1341
1350
  const tableData = normalized.tableData ?? [];
1351
+ // Row/column `order` must be valid fractional-indexing keys. AFFiNE's
1352
+ // editor computes the next order with generateKeyBetween(prevOrder, ...)
1353
+ // when inserting a row/column; plain strings like "r0000" render but make
1354
+ // that call throw "invalid order key", so rows/columns cannot be added in
1355
+ // the UI. Use real keys (a0, a1, ...) so insertion works.
1356
+ const rowOrders = generateNKeysBetween(null, null, normalized.rows);
1357
+ const columnOrders = generateNKeysBetween(null, null, normalized.columns);
1342
1358
  for (let i = 0; i < normalized.rows; i++) {
1343
1359
  const rowId = generateId();
1344
1360
  block.set(`prop:rows.${rowId}.rowId`, rowId);
1345
- block.set(`prop:rows.${rowId}.order`, `r${String(i).padStart(4, "0")}`);
1361
+ block.set(`prop:rows.${rowId}.order`, rowOrders[i]);
1346
1362
  rowIds.push(rowId);
1347
1363
  }
1348
1364
  for (let i = 0; i < normalized.columns; i++) {
1349
1365
  const columnId = generateId();
1350
1366
  block.set(`prop:columns.${columnId}.columnId`, columnId);
1351
- block.set(`prop:columns.${columnId}.order`, `c${String(i).padStart(4, "0")}`);
1367
+ block.set(`prop:columns.${columnId}.order`, columnOrders[i]);
1352
1368
  columnIds.push(columnId);
1353
1369
  }
1354
1370
  for (let rowIndex = 0; rowIndex < rowIds.length; rowIndex += 1) {
@@ -2073,6 +2089,13 @@ export function registerDocTools(server, gql, defaults) {
2073
2089
  return [];
2074
2090
  }
2075
2091
  function extractTableData(block) {
2092
+ const compareOrder = (left, right) => {
2093
+ if (left < right)
2094
+ return -1;
2095
+ if (left > right)
2096
+ return 1;
2097
+ return 0;
2098
+ };
2076
2099
  const rowsValue = block.get("prop:rows");
2077
2100
  const columnsValue = block.get("prop:columns");
2078
2101
  const cellsValue = block.get("prop:cells");
@@ -2083,7 +2106,7 @@ export function registerDocTools(server, gql, defaults) {
2083
2106
  ? payload.order
2084
2107
  : rowId,
2085
2108
  }))
2086
- .sort((a, b) => a.order.localeCompare(b.order));
2109
+ .sort((a, b) => compareOrder(a.order, b.order));
2087
2110
  let columnEntries = mapEntries(columnsValue)
2088
2111
  .map(([columnId, payload]) => ({
2089
2112
  columnId,
@@ -2091,7 +2114,7 @@ export function registerDocTools(server, gql, defaults) {
2091
2114
  ? payload.order
2092
2115
  : columnId,
2093
2116
  }))
2094
- .sort((a, b) => a.order.localeCompare(b.order));
2117
+ .sort((a, b) => compareOrder(a.order, b.order));
2095
2118
  let cells = new Map();
2096
2119
  if (rowEntries.length === 0 || columnEntries.length === 0) {
2097
2120
  // Fallback: AFFiNE self-hosted stores table props as flat dot-notation keys
@@ -2121,10 +2144,10 @@ export function registerDocTools(server, gql, defaults) {
2121
2144
  if (flatRows.size > 0 && flatColumns.size > 0) {
2122
2145
  rowEntries = Array.from(flatRows.entries())
2123
2146
  .map(([rowId, order]) => ({ rowId, order }))
2124
- .sort((a, b) => a.order.localeCompare(b.order));
2147
+ .sort((a, b) => compareOrder(a.order, b.order));
2125
2148
  columnEntries = Array.from(flatColumns.entries())
2126
2149
  .map(([columnId, order]) => ({ columnId, order }))
2127
- .sort((a, b) => a.order.localeCompare(b.order));
2150
+ .sort((a, b) => compareOrder(a.order, b.order));
2128
2151
  cells = flatCells;
2129
2152
  }
2130
2153
  }
@@ -3387,6 +3410,88 @@ export function registerDocTools(server, gql, defaults) {
3387
3410
  sortDirection: z.enum(["asc", "desc"]).optional().describe("Sort direction for updatedAt sorting (default: desc)."),
3388
3411
  },
3389
3412
  }, searchDocsHandler);
3413
+ const findDocByTitleHandler = async (parsed) => {
3414
+ const workspaceId = parsed.workspaceId || defaults.workspaceId;
3415
+ if (!workspaceId) {
3416
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
3417
+ }
3418
+ const title = parsed.title;
3419
+ if (!title || title.length === 0) {
3420
+ throw new Error("title is required and must be non-empty.");
3421
+ }
3422
+ const limit = parsed.limit ?? 50;
3423
+ const caseInsensitive = parsed.caseInsensitive ?? false;
3424
+ const target = caseInsensitive ? title.toLocaleLowerCase() : title;
3425
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3426
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3427
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3428
+ try {
3429
+ await joinWorkspace(socket, workspaceId);
3430
+ const snapshot = await loadDoc(socket, workspaceId, workspaceId);
3431
+ if (!snapshot.missing) {
3432
+ return text({
3433
+ query: title,
3434
+ caseInsensitive,
3435
+ matches: [],
3436
+ workspaceDocCount: 0,
3437
+ truncated: false,
3438
+ });
3439
+ }
3440
+ const wsDoc = new Y.Doc();
3441
+ Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
3442
+ const meta = wsDoc.getMap("meta");
3443
+ const pages = getWorkspacePageEntries(meta);
3444
+ const matches = [];
3445
+ let truncated = false;
3446
+ for (const page of pages) {
3447
+ const pageTitle = page.title ?? "";
3448
+ const candidate = caseInsensitive ? pageTitle.toLocaleLowerCase() : pageTitle;
3449
+ if (candidate !== target)
3450
+ continue;
3451
+ if (matches.length >= limit) {
3452
+ // We hit `limit` on a previous iteration and now found another match —
3453
+ // there are genuinely more matches than the cap.
3454
+ truncated = true;
3455
+ break;
3456
+ }
3457
+ // A doc that has never been edited after creation has no
3458
+ // `updatedDate` in workspace meta — fall back to `createDate` so
3459
+ // `updatedAt` is always populated, consistent with `search_docs`.
3460
+ const updatedTimestamp = page.updatedDate ?? page.createDate;
3461
+ matches.push({
3462
+ id: page.id,
3463
+ title: pageTitle,
3464
+ createdAt: page.createDate ? new Date(page.createDate).toISOString() : null,
3465
+ updatedAt: updatedTimestamp ? new Date(updatedTimestamp).toISOString() : null,
3466
+ });
3467
+ }
3468
+ return text({
3469
+ query: title,
3470
+ caseInsensitive,
3471
+ matches,
3472
+ workspaceDocCount: pages.length,
3473
+ truncated,
3474
+ });
3475
+ }
3476
+ finally {
3477
+ socket.disconnect();
3478
+ }
3479
+ };
3480
+ server.registerTool("find_doc_by_title", {
3481
+ title: "Find Doc by Title",
3482
+ description: "Resolve docs by exact title. Returns ALL matches up to `limit` (callers handle ambiguity). " +
3483
+ "Case-sensitive by default; pass `caseInsensitive: true` to fold case. " +
3484
+ "Reads workspace metadata — fast, no per-doc fetch. " +
3485
+ "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). " +
3486
+ "Prefer this over `search_docs` when you know the exact title and want every match. " +
3487
+ "Returns: { query, caseInsensitive, matches: [{ id, title, createdAt, updatedAt }], workspaceDocCount, truncated }.",
3488
+ inputSchema: {
3489
+ workspaceId: z.string().optional().describe("Workspace ID (optional if AFFINE_WORKSPACE_ID is set)."),
3490
+ title: z.string().min(1).describe("The exact title to match."),
3491
+ caseInsensitive: z.boolean().optional().describe("If true, fold case for comparison (default: false)."),
3492
+ limit: z.number().int().positive().max(200).optional().describe("Max matches to return (default: 50)."),
3493
+ },
3494
+ }, findDocByTitleHandler);
3390
3495
  server.registerTool("list_tags", {
3391
3496
  title: "List Tags",
3392
3497
  description: "List all tags in a workspace and the number of docs attached to each tag.",
@@ -3705,7 +3810,9 @@ export function registerDocTools(server, gql, defaults) {
3705
3810
  const flavour = raw.get("sys:flavour");
3706
3811
  const parentId = raw.get("sys:parent");
3707
3812
  const type = raw.get("prop:type");
3708
- const textValue = asText(raw.get("prop:text"));
3813
+ const propText = raw.get("prop:text");
3814
+ const textValue = asText(propText);
3815
+ const linkedDocIds = extractLinkedPageRefs(propText);
3709
3816
  const language = raw.get("prop:language");
3710
3817
  const checked = raw.get("prop:checked");
3711
3818
  const childIds = childIdsFrom(raw.get("sys:children"));
@@ -3721,6 +3828,7 @@ export function registerDocTools(server, gql, defaults) {
3721
3828
  flavour: typeof flavour === "string" ? flavour : null,
3722
3829
  type: typeof type === "string" ? type : null,
3723
3830
  text: textValue.length > 0 ? textValue : null,
3831
+ linkedDocIds,
3724
3832
  checked: typeof checked === "boolean" ? checked : null,
3725
3833
  language: typeof language === "string" ? language : null,
3726
3834
  childIds,
@@ -4033,23 +4141,51 @@ export function registerDocTools(server, gql, defaults) {
4033
4141
  parentDocId: parsed.parentDocId,
4034
4142
  context: "create_doc",
4035
4143
  });
4144
+ const warnings = mergeWarnings(created.warnings ?? [], placement.warnings);
4145
+ let linkedFolderId = null;
4146
+ let folderNodeId = null;
4147
+ if (parsed.folderId) {
4148
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4149
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4150
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4151
+ try {
4152
+ await joinWorkspace(socket, created.workspaceId);
4153
+ const link = await addOrganizeLinkToFolder(socket, created.workspaceId, {
4154
+ folderId: parsed.folderId,
4155
+ type: "doc",
4156
+ targetId: created.docId,
4157
+ });
4158
+ linkedFolderId = link.parentId;
4159
+ folderNodeId = link.id;
4160
+ }
4161
+ catch (err) {
4162
+ warnings.push(`Doc created but could not be placed in folder "${parsed.folderId}": ${err?.message ?? "unknown error"}`);
4163
+ }
4164
+ finally {
4165
+ socket.disconnect();
4166
+ }
4167
+ }
4036
4168
  return receipt("doc.create", {
4037
4169
  workspaceId: created.workspaceId,
4038
4170
  docId: created.docId,
4039
4171
  title: created.title,
4040
4172
  parentDocId: placement.parentDocId,
4041
4173
  linkedToParent: placement.linkedToParent,
4042
- warnings: mergeWarnings(created.warnings ?? [], placement.warnings),
4174
+ folderId: linkedFolderId,
4175
+ folderLinked: folderNodeId !== null,
4176
+ folderNodeId,
4177
+ warnings,
4043
4178
  });
4044
4179
  };
4045
4180
  server.registerTool('create_doc', {
4046
4181
  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.',
4182
+ 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
4183
  inputSchema: {
4049
4184
  workspaceId: z.string().optional(),
4050
4185
  title: z.string().optional(),
4051
4186
  content: z.string().optional(),
4052
4187
  parentDocId: z.string().optional().describe("Optional parent doc to link the new doc under in the sidebar."),
4188
+ folderId: z.string().optional().describe("Optional folder ID to place the doc in. Use list_organize_nodes to find folder IDs."),
4053
4189
  },
4054
4190
  }, createDocHandler);
4055
4191
  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();
@@ -0,0 +1,426 @@
1
+ import { z } from "zod";
2
+ import { generateKeyBetween } from "fractional-indexing";
3
+ import * as Y from "yjs";
4
+ import { text } from "../util/mcp.js";
5
+ import { wsUrlFromGraphQLEndpoint, connectWorkspaceSocket, joinWorkspace, loadDoc, pushDocUpdate, } from "../ws.js";
6
+ /**
7
+ * Doc custom properties live in dedicated Yjs sub-docs synced by guid, NOT in
8
+ * the page doc or the workspace root meta. AFFiNE's WorkspaceDB (an ORM on top
9
+ * of Yjs) maps one table to one sub-doc whose guid is `db$<tableName>`:
10
+ *
11
+ * - `db$docCustomPropertyInfo`: the workspace-wide property *definitions*
12
+ * (schema). Top-level YMap keyed by propertyId -> { id, name, type, index,
13
+ * icon, show, isDeleted }.
14
+ * - `db$docProperties`: the per-doc property *values*. Top-level YMap keyed by
15
+ * docId -> { id, ...builtins, "custom:<propertyId>": <value> }.
16
+ *
17
+ * A custom property must have a definition in `db$docCustomPropertyInfo` to be
18
+ * rendered/editable in the AFFiNE UI. Writing a value without a matching
19
+ * definition stores orphan data that the UI ignores.
20
+ *
21
+ * Values are stored as strings, encoded per type:
22
+ * - text: raw string
23
+ * - number: stringified number
24
+ * - checkbox: "true" | "false"
25
+ * - date: "YYYY-MM-DD"
26
+ *
27
+ * References (AFFiNE repo):
28
+ * - modules/db/services/db.ts -> guid `db$${tableName}`
29
+ * - orm/core/adapters/yjs/table.ts -> record = top-level YMap keyed by primary key
30
+ * - modules/doc/entities/record.ts -> value key is `custom:<propertyId>`
31
+ */
32
+ const DOC_PROPERTIES_GUID = "db$docProperties";
33
+ const CUSTOM_PROPERTY_INFO_GUID = "db$docCustomPropertyInfo";
34
+ const DELETED_FLAG = "$$DELETED";
35
+ const CUSTOM_PREFIX = "custom:";
36
+ const SUPPORTED_TYPES = ["text", "number", "checkbox", "date"];
37
+ const WorkspaceId = z.string().min(1, "workspaceId required");
38
+ const DocId = z.string().min(1, "docId required");
39
+ const NANOID_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
40
+ /** Generate a 21-char nanoid-style id, matching AFFiNE's property id format. */
41
+ function generatePropertyId() {
42
+ let id = "";
43
+ for (let i = 0; i < 21; i++) {
44
+ id += NANOID_ALPHABET.charAt(Math.floor(Math.random() * NANOID_ALPHABET.length));
45
+ }
46
+ return id;
47
+ }
48
+ /**
49
+ * Read all live custom-property definitions from the `db$docCustomPropertyInfo`
50
+ * sub-doc, skipping soft-deleted and empty records.
51
+ */
52
+ function readPropertyDefinitions(doc) {
53
+ const defs = [];
54
+ // Records are top-level (root) Yjs types keyed by id. After applyUpdate, root
55
+ // types are generic AbstractType until doc.getMap(key) casts them, so an
56
+ // `instanceof Y.Map` check would skip every record. Mirror AFFiNE's ORM
57
+ // adapter and materialize each record via getMap.
58
+ for (const key of doc.share.keys()) {
59
+ const data = doc.getMap(key).toJSON();
60
+ if (data[DELETED_FLAG] === true || data.isDeleted === true)
61
+ continue;
62
+ if (Object.keys(data).length === 0)
63
+ continue;
64
+ const type = typeof data.type === "string" ? data.type : "unknown";
65
+ defs.push({
66
+ id: typeof data.id === "string" ? data.id : key,
67
+ name: typeof data.name === "string" ? data.name : null,
68
+ type,
69
+ index: typeof data.index === "string" ? data.index : null,
70
+ icon: typeof data.icon === "string" ? data.icon : null,
71
+ show: typeof data.show === "string" ? data.show : null,
72
+ });
73
+ }
74
+ defs.sort((a, b) => (a.index || "").localeCompare(b.index || ""));
75
+ return defs;
76
+ }
77
+ /**
78
+ * Resolve a definition by exact id, then by unique case-insensitive name.
79
+ * Throws if a name matches more than one definition.
80
+ */
81
+ function resolveDefinition(defs, property) {
82
+ const byId = defs.find((d) => d.id === property);
83
+ if (byId)
84
+ return byId;
85
+ const lowered = property.trim().toLowerCase();
86
+ const byName = defs.filter((d) => (d.name || "").trim().toLowerCase() === lowered);
87
+ if (byName.length === 1)
88
+ return byName[0];
89
+ if (byName.length > 1) {
90
+ throw new Error(`Property name "${property}" is ambiguous (${byName.length} matches). Use the property id instead.`);
91
+ }
92
+ return null;
93
+ }
94
+ /** Compute the next fractional index, appending after the current last definition. */
95
+ function nextIndex(defs) {
96
+ const indexes = defs
97
+ .map((d) => d.index)
98
+ .filter((i) => typeof i === "string" && i.length > 0)
99
+ .sort();
100
+ const last = indexes.length ? indexes[indexes.length - 1] : null;
101
+ return generateKeyBetween(last, null);
102
+ }
103
+ /** Encode a JS value into AFFiNE's per-type string representation; throws on invalid input. */
104
+ function encodeValue(type, value) {
105
+ switch (type) {
106
+ case "checkbox": {
107
+ const truthy = value === true ||
108
+ value === 1 ||
109
+ (typeof value === "string" && ["true", "1", "yes"].includes(value.trim().toLowerCase()));
110
+ return truthy ? "true" : "false";
111
+ }
112
+ case "number": {
113
+ const n = typeof value === "number" ? value : Number(String(value).trim());
114
+ if (!Number.isFinite(n)) {
115
+ throw new Error(`number property requires a numeric value, got ${JSON.stringify(value)}`);
116
+ }
117
+ return String(n);
118
+ }
119
+ case "date": {
120
+ const s = String(value).trim();
121
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
122
+ throw new Error(`date property requires "YYYY-MM-DD", got ${JSON.stringify(value)}`);
123
+ }
124
+ const parsed = new Date(`${s}T00:00:00.000Z`);
125
+ if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== s) {
126
+ throw new Error(`date property requires a valid "YYYY-MM-DD" date, got ${JSON.stringify(value)}`);
127
+ }
128
+ return s;
129
+ }
130
+ case "text":
131
+ default:
132
+ return String(value);
133
+ }
134
+ }
135
+ /** Decode a stored string back into a typed JS value for output. */
136
+ function decodeValue(type, raw) {
137
+ if (raw === undefined || raw === null)
138
+ return null;
139
+ switch (type) {
140
+ case "checkbox":
141
+ return raw === "true" || raw === true;
142
+ case "number": {
143
+ const n = Number(raw);
144
+ return Number.isFinite(n) ? n : raw;
145
+ }
146
+ default:
147
+ return raw;
148
+ }
149
+ }
150
+ /** Register the five document custom-property tools on the MCP server. */
151
+ export function registerPropertyTools(server, gql, defaults) {
152
+ /** Snapshot the current GraphQL endpoint and auth material for WebSocket use. */
153
+ function getCookieAndEndpoint() {
154
+ return { endpoint: gql.endpoint, cookie: gql.cookie, bearer: gql.bearer };
155
+ }
156
+ /** Resolve the workspace id from the argument or the configured default; throws if absent. */
157
+ function requireWorkspaceId(workspaceId) {
158
+ const id = workspaceId || defaults.workspaceId;
159
+ if (!id) {
160
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
161
+ }
162
+ return id;
163
+ }
164
+ /** Load a WorkspaceDB sub-doc by guid and return it with its pre-mutation state vector. */
165
+ async function loadSubdoc(socket, workspaceId, guid) {
166
+ const snapshot = await loadDoc(socket, workspaceId, guid);
167
+ const doc = new Y.Doc();
168
+ let existed = false;
169
+ if (snapshot.missing) {
170
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
171
+ existed = true;
172
+ }
173
+ return { doc, prevSV: Y.encodeStateVector(doc), existed };
174
+ }
175
+ /** Push only the delta accumulated since `prevSV` back to the sync gateway. */
176
+ async function pushSubdoc(socket, workspaceId, guid, doc, prevSV) {
177
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
178
+ await pushDocUpdate(socket, workspaceId, guid, Buffer.from(delta).toString("base64"));
179
+ }
180
+ /** Throw if the workspace root or the docId is not found in workspace metadata. */
181
+ async function assertDocExists(socket, workspaceId, docId) {
182
+ const snapshot = await loadDoc(socket, workspaceId, workspaceId);
183
+ if (!snapshot.missing) {
184
+ throw new Error(`Workspace root document not found for workspace ${workspaceId}`);
185
+ }
186
+ const wsDoc = new Y.Doc();
187
+ Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
188
+ const pages = wsDoc.getMap("meta").get("pages");
189
+ const exists = pages instanceof Y.Array &&
190
+ pages.toArray().some((p) => p instanceof Y.Map && p.get("id") === docId);
191
+ if (!exists) {
192
+ throw new Error(`docId ${docId} is not present in workspace ${workspaceId}`);
193
+ }
194
+ }
195
+ // ---------------------------------------------------------------------------
196
+ // list_doc_properties
197
+ // ---------------------------------------------------------------------------
198
+ /** Handle `list_doc_properties`: definitions, decoded per-doc values, and orphan values. */
199
+ const listDocPropertiesHandler = async (parsed) => {
200
+ const workspaceId = requireWorkspaceId(parsed.workspaceId);
201
+ const { endpoint, cookie, bearer } = getCookieAndEndpoint();
202
+ const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
203
+ try {
204
+ await joinWorkspace(socket, workspaceId);
205
+ const { doc: infoDoc } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
206
+ const defs = readPropertyDefinitions(infoDoc);
207
+ const { doc: propsDoc } = await loadSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID);
208
+ const record = propsDoc.share.has(parsed.docId)
209
+ ? propsDoc.getMap(parsed.docId).toJSON()
210
+ : {};
211
+ const byId = new Map(defs.map((d) => [d.id, d]));
212
+ const properties = defs.map((def) => {
213
+ const raw = record[CUSTOM_PREFIX + def.id];
214
+ return {
215
+ propertyId: def.id,
216
+ name: def.name,
217
+ type: def.type,
218
+ value: decodeValue(def.type, raw),
219
+ set: raw !== undefined && raw !== null,
220
+ };
221
+ });
222
+ // Surface custom values that have no matching (live) definition.
223
+ const orphans = Object.keys(record)
224
+ .filter((k) => k.startsWith(CUSTOM_PREFIX))
225
+ .map((k) => k.slice(CUSTOM_PREFIX.length))
226
+ .filter((id) => !byId.has(id))
227
+ .map((id) => ({ propertyId: id, value: record[CUSTOM_PREFIX + id] }));
228
+ return text({
229
+ workspaceId,
230
+ docId: parsed.docId,
231
+ definitions: defs,
232
+ properties,
233
+ orphanValues: orphans,
234
+ });
235
+ }
236
+ finally {
237
+ socket.disconnect();
238
+ }
239
+ };
240
+ server.registerTool("list_doc_properties", {
241
+ title: "List Document Properties",
242
+ description: "List the workspace custom-property definitions and a document's current values for them.",
243
+ inputSchema: {
244
+ workspaceId: WorkspaceId.optional(),
245
+ docId: DocId,
246
+ },
247
+ }, listDocPropertiesHandler);
248
+ // ---------------------------------------------------------------------------
249
+ // create_custom_property
250
+ // ---------------------------------------------------------------------------
251
+ /** Handle `create_custom_property`: append a new workspace-wide definition. */
252
+ const createCustomPropertyHandler = async (parsed) => {
253
+ const workspaceId = requireWorkspaceId(parsed.workspaceId);
254
+ const name = parsed.name.trim();
255
+ if (!name)
256
+ throw new Error("name is required");
257
+ const { endpoint, cookie, bearer } = getCookieAndEndpoint();
258
+ const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
259
+ try {
260
+ await joinWorkspace(socket, workspaceId);
261
+ const { doc, prevSV } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
262
+ const defs = readPropertyDefinitions(doc);
263
+ const id = generatePropertyId();
264
+ const index = nextIndex(defs);
265
+ const record = doc.getMap(id);
266
+ record.set("id", id);
267
+ record.set("name", name);
268
+ record.set("type", parsed.type);
269
+ record.set("index", index);
270
+ if (parsed.icon)
271
+ record.set("icon", parsed.icon);
272
+ await pushSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID, doc, prevSV);
273
+ return text({
274
+ workspaceId,
275
+ propertyId: id,
276
+ name,
277
+ type: parsed.type,
278
+ index,
279
+ created: true,
280
+ });
281
+ }
282
+ finally {
283
+ socket.disconnect();
284
+ }
285
+ };
286
+ server.registerTool("create_custom_property", {
287
+ title: "Create Custom Property",
288
+ description: "Create a workspace-wide custom property definition (text, number, checkbox, or date). Returns its propertyId.",
289
+ inputSchema: {
290
+ workspaceId: WorkspaceId.optional(),
291
+ name: z.string().min(1).describe("Display name of the property"),
292
+ type: z.enum(SUPPORTED_TYPES).describe("Property value type"),
293
+ icon: z.string().optional().describe("Optional icon name"),
294
+ },
295
+ }, createCustomPropertyHandler);
296
+ // ---------------------------------------------------------------------------
297
+ // delete_custom_property
298
+ // ---------------------------------------------------------------------------
299
+ /** Handle `delete_custom_property`: soft-delete a definition by id or name. */
300
+ const deleteCustomPropertyHandler = async (parsed) => {
301
+ const workspaceId = requireWorkspaceId(parsed.workspaceId);
302
+ const { endpoint, cookie, bearer } = getCookieAndEndpoint();
303
+ const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
304
+ try {
305
+ await joinWorkspace(socket, workspaceId);
306
+ const { doc, prevSV } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
307
+ const defs = readPropertyDefinitions(doc);
308
+ const def = resolveDefinition(defs, parsed.property);
309
+ if (!def) {
310
+ throw new Error(`No custom property matches "${parsed.property}" in workspace ${workspaceId}`);
311
+ }
312
+ // Mirror AFFiNE: keep the record for legacy override, flag it deleted.
313
+ const record = doc.getMap(def.id);
314
+ record.set("isDeleted", true);
315
+ await pushSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID, doc, prevSV);
316
+ return text({ workspaceId, propertyId: def.id, name: def.name, deleted: true });
317
+ }
318
+ finally {
319
+ socket.disconnect();
320
+ }
321
+ };
322
+ server.registerTool("delete_custom_property", {
323
+ title: "Delete Custom Property",
324
+ description: "Soft-delete a workspace custom property definition (by propertyId or name). Existing values are hidden.",
325
+ inputSchema: {
326
+ workspaceId: WorkspaceId.optional(),
327
+ property: z.string().min(1).describe("Property id or name"),
328
+ },
329
+ }, deleteCustomPropertyHandler);
330
+ // ---------------------------------------------------------------------------
331
+ // set_doc_property
332
+ // ---------------------------------------------------------------------------
333
+ /** Handle `set_doc_property`: validate, encode, and upsert a doc's property value. */
334
+ const setDocPropertyHandler = async (parsed) => {
335
+ const workspaceId = requireWorkspaceId(parsed.workspaceId);
336
+ const { endpoint, cookie, bearer } = getCookieAndEndpoint();
337
+ const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
338
+ try {
339
+ await joinWorkspace(socket, workspaceId);
340
+ await assertDocExists(socket, workspaceId, parsed.docId);
341
+ const { doc: infoDoc } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
342
+ const defs = readPropertyDefinitions(infoDoc);
343
+ const def = resolveDefinition(defs, parsed.property);
344
+ if (!def) {
345
+ throw new Error(`No custom property matches "${parsed.property}". Create it first with create_custom_property.`);
346
+ }
347
+ if (!SUPPORTED_TYPES.includes(def.type)) {
348
+ throw new Error(`Property "${def.name || def.id}" has type "${def.type}", which set_doc_property cannot edit. Supported: ${SUPPORTED_TYPES.join(", ")}.`);
349
+ }
350
+ const encoded = encodeValue(def.type, parsed.value);
351
+ const { doc, prevSV } = await loadSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID);
352
+ const record = doc.getMap(parsed.docId);
353
+ record.set("id", parsed.docId); // ORM keyField, required by find/observe
354
+ record.set(CUSTOM_PREFIX + def.id, encoded);
355
+ await pushSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID, doc, prevSV);
356
+ return text({
357
+ workspaceId,
358
+ docId: parsed.docId,
359
+ propertyId: def.id,
360
+ name: def.name,
361
+ type: def.type,
362
+ value: decodeValue(def.type, encoded),
363
+ stored: encoded,
364
+ updated: true,
365
+ });
366
+ }
367
+ finally {
368
+ socket.disconnect();
369
+ }
370
+ };
371
+ server.registerTool("set_doc_property", {
372
+ title: "Set Document Property",
373
+ description: "Set a document's custom property value (property by id or name). Value is validated against the property type (text/number/checkbox/date).",
374
+ inputSchema: {
375
+ workspaceId: WorkspaceId.optional(),
376
+ docId: DocId,
377
+ property: z.string().min(1).describe("Property id or name"),
378
+ value: z
379
+ .union([z.string(), z.number(), z.boolean()])
380
+ .describe("Value; coerced per property type (checkbox->bool, number, date YYYY-MM-DD, text)"),
381
+ },
382
+ }, setDocPropertyHandler);
383
+ // ---------------------------------------------------------------------------
384
+ // clear_doc_property
385
+ // ---------------------------------------------------------------------------
386
+ /** Handle `clear_doc_property`: remove a doc's value for a property (by id or name). */
387
+ const clearDocPropertyHandler = async (parsed) => {
388
+ const workspaceId = requireWorkspaceId(parsed.workspaceId);
389
+ const { endpoint, cookie, bearer } = getCookieAndEndpoint();
390
+ const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
391
+ try {
392
+ await joinWorkspace(socket, workspaceId);
393
+ const { doc: infoDoc } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
394
+ const defs = readPropertyDefinitions(infoDoc);
395
+ const def = resolveDefinition(defs, parsed.property);
396
+ // Allow clearing by raw id even if the definition was already deleted.
397
+ const propertyId = def?.id ?? parsed.property;
398
+ const { doc, prevSV } = await loadSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID);
399
+ let cleared = false;
400
+ if (doc.share.has(parsed.docId)) {
401
+ const record = doc.getMap(parsed.docId);
402
+ const key = CUSTOM_PREFIX + propertyId;
403
+ if (record.has(key)) {
404
+ record.delete(key);
405
+ cleared = true;
406
+ }
407
+ }
408
+ if (cleared) {
409
+ await pushSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID, doc, prevSV);
410
+ }
411
+ return text({ workspaceId, docId: parsed.docId, propertyId, cleared });
412
+ }
413
+ finally {
414
+ socket.disconnect();
415
+ }
416
+ };
417
+ server.registerTool("clear_doc_property", {
418
+ title: "Clear Document Property",
419
+ description: "Remove a custom property value from a document (property by id or name).",
420
+ inputSchema: {
421
+ workspaceId: WorkspaceId.optional(),
422
+ docId: DocId,
423
+ property: z.string().min(1).describe("Property id or name"),
424
+ },
425
+ }, clearDocPropertyHandler);
426
+ }
@@ -55,7 +55,7 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
55
55
  | `search_docs` | Search titles with substring, prefix, or exact matching | Supports tag filter and updatedAt sorting |
56
56
  | `list_docs_by_tag` | List documents with a specific tag | |
57
57
  | `get_doc` | Read document metadata | |
58
- | `read_doc` | Read block content and plain text snapshot | WebSocket-backed |
58
+ | `read_doc` | Read block content and plain text snapshot | WebSocket-backed; block rows include `linkedDocIds` for inline LinkedPage references |
59
59
  | `get_capabilities` | Inspect the server's high-level authoring and fidelity capabilities | Useful for adaptive clients |
60
60
  | `analyze_doc_fidelity` | Analyze how a document maps to Markdown and which native AFFiNE structures are lossy | Good before export or migration |
61
61
  | `list_children` | List direct child docs linked from a document | |
@@ -97,6 +97,16 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
97
97
  | `add_tag_to_doc` | Attach a tag to a document | |
98
98
  | `remove_tag_from_doc` | Detach a tag from a document | |
99
99
 
100
+ ### Custom properties
101
+
102
+ | Tool | Purpose | Notes |
103
+ | --- | --- | --- |
104
+ | `list_doc_properties` | List workspace custom-property definitions and a document's current values | WebSocket-backed; reads the `db$docProperties` / `db$docCustomPropertyInfo` sub-docs |
105
+ | `create_custom_property` | Create a workspace-wide custom property definition | Types: `text`, `number`, `checkbox`, `date`. Returns the `propertyId` |
106
+ | `delete_custom_property` | Soft-delete a custom property definition by id or name | Destructive; existing values are hidden |
107
+ | `set_doc_property` | Set a document's custom property value by property id or name | Value validated per type (`checkbox` boolean, `number`, `date` `YYYY-MM-DD`, `text`) |
108
+ | `clear_doc_property` | Remove a custom property value from a document | |
109
+
100
110
  ### Markdown export
101
111
 
102
112
  | Tool | Purpose | Notes |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "affine-mcp-server",
3
- "version": "2.0.0",
3
+ "version": "2.2.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",
@@ -43,10 +43,14 @@
43
43
  "test:e2e": "bash tests/run-e2e.sh",
44
44
  "test:db-create": "node tests/test-database-creation.mjs",
45
45
  "test:db-cells": "node tests/test-database-cells.mjs",
46
+ "test:db-linked-doc": "node tests/test-database-linked-doc.mjs",
46
47
  "test:db-ui-rows": "node tests/test-database-ui-rows.mjs",
47
48
  "test:db-schema": "node tests/test-database-schema.mjs",
48
49
  "test:data-view": "node tests/test-data-view.mjs",
49
50
  "test:doc-discovery": "node tests/test-doc-discovery.mjs",
51
+ "test:doc-properties": "node tests/test-doc-properties.mjs",
52
+ "test:read-doc-linked-refs": "node tests/test-read-doc-linked-refs.mjs",
53
+ "test:find-doc-by-title": "node tests/test-find-doc-by-title.mjs",
50
54
  "test:create-placement": "node tests/test-create-placement.mjs",
51
55
  "test:surface-elements": "node tests/test-surface-elements.mjs",
52
56
  "test:surface-element-gating": "node scripts/verify-surface-element-gating.mjs",
@@ -60,6 +64,7 @@
60
64
  "test:http-bearer": "node tests/test-http-bearer.mjs",
61
65
  "test:oauth-http": "node tests/test-oauth-http.mjs",
62
66
  "test:organize": "node tests/test-organize-tools.mjs",
67
+ "test:create-doc-folder-placement": "node tests/test-create-doc-folder-placement.mjs",
63
68
  "test:supporting-tools": "node tests/test-supporting-tools.mjs",
64
69
  "test:tag-visibility": "node tests/test-tag-visibility.mjs",
65
70
  "test:markdown-roundtrip": "node tests/test-markdown-roundtrip.mjs",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.0.0",
2
+ "version": "2.2.0",
3
3
  "tools": [
4
4
  "add_database_column",
5
5
  "add_database_row",
@@ -12,9 +12,11 @@
12
12
  "append_markdown",
13
13
  "append_semantic_section",
14
14
  "cleanup_blobs",
15
+ "clear_doc_property",
15
16
  "compose_database_from_intent",
16
17
  "create_collection",
17
18
  "create_comment",
19
+ "create_custom_property",
18
20
  "create_doc",
19
21
  "create_doc_from_markdown",
20
22
  "create_folder",
@@ -27,6 +29,7 @@
27
29
  "delete_block",
28
30
  "delete_collection",
29
31
  "delete_comment",
32
+ "delete_custom_property",
30
33
  "delete_database_row",
31
34
  "delete_doc",
32
35
  "delete_folder",
@@ -35,6 +38,7 @@
35
38
  "delete_workspace",
36
39
  "export_doc_markdown",
37
40
  "export_with_fidelity_report",
41
+ "find_doc_by_title",
38
42
  "generate_access_token",
39
43
  "get_capabilities",
40
44
  "get_collection",
@@ -48,6 +52,7 @@
48
52
  "list_children",
49
53
  "list_collections",
50
54
  "list_comments",
55
+ "list_doc_properties",
51
56
  "list_docs",
52
57
  "list_docs_by_tag",
53
58
  "list_histories",
@@ -72,6 +77,7 @@
72
77
  "revoke_access_token",
73
78
  "revoke_doc",
74
79
  "search_docs",
80
+ "set_doc_property",
75
81
  "sign_in",
76
82
  "update_collection",
77
83
  "update_collection_rules",