affine-mcp-server 2.1.0 → 2.3.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.1.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.3.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 85 canonical MCP tools backed by AFFiNE GraphQL and WebSocket APIs
41
+ - Exposes 94 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.1.0: Added exact-title document lookup, folder placement for `create_doc`, and trusted npm publishing.
51
+ > New in v2.3.0: Added document and folder sidebar icon tools, plus richer hierarchy detection for inline LinkedPage references and synced-doc embeds.
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,8 @@ 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";
18
+ import { registerIconTools } from "./tools/icons.js";
17
19
  import { runCli } from "./cli.js";
18
20
  import { startHttpMcpServer } from "./sse.js";
19
21
  import { existsSync } from "fs";
@@ -165,6 +167,8 @@ async function buildServer() {
165
167
  registerCommentTools(server, gql, { workspaceId: config.defaultWorkspaceId });
166
168
  registerHistoryTools(server, gql, { workspaceId: config.defaultWorkspaceId });
167
169
  registerOrganizeTools(server, gql, { workspaceId: config.defaultWorkspaceId });
170
+ registerPropertyTools(server, gql, { workspaceId: config.defaultWorkspaceId });
171
+ registerIconTools(server, gql, { workspaceId: config.defaultWorkspaceId });
168
172
  registerUserTools(server, gql);
169
173
  registerUserCRUDTools(server, gql);
170
174
  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",
@@ -38,7 +41,9 @@ const ALL_TOOLS = [
38
41
  "get_capabilities",
39
42
  "get_collection",
40
43
  "get_doc",
44
+ "get_doc_icon",
41
45
  "get_edgeless_canvas",
46
+ "get_folder_icon",
42
47
  "get_orphan_docs",
43
48
  "get_workspace",
44
49
  "inspect_template_structure",
@@ -47,6 +52,7 @@ const ALL_TOOLS = [
47
52
  "list_children",
48
53
  "list_collections",
49
54
  "list_comments",
55
+ "list_doc_properties",
50
56
  "list_docs",
51
57
  "list_docs_by_tag",
52
58
  "list_histories",
@@ -71,13 +77,16 @@ const ALL_TOOLS = [
71
77
  "revoke_access_token",
72
78
  "revoke_doc",
73
79
  "search_docs",
80
+ "set_doc_property",
74
81
  "sign_in",
75
82
  "update_collection",
76
83
  "update_collection_rules",
77
84
  "update_comment",
78
85
  "update_database_row",
86
+ "update_doc_icon",
79
87
  "update_doc_title",
80
88
  "update_edgeless_block",
89
+ "update_folder_icon",
81
90
  "update_frame_children",
82
91
  "update_profile",
83
92
  "update_settings",
@@ -97,9 +106,11 @@ const TOOL_GROUPS = {
97
106
  append_markdown: ["docs", "docs.markdown", "docs.write", "write"],
98
107
  append_semantic_section: ["docs", "docs.semantic", "docs.write", "write"],
99
108
  cleanup_blobs: ["blobs", "blobs.write", "cleanup", "destructive", "write"],
109
+ clear_doc_property: ["docs", "docs.properties", "docs.write", "write"],
100
110
  compose_database_from_intent: ["docs", "docs.database", "docs.intent", "docs.write", "write"],
101
111
  create_collection: ["organize", "organize.collections", "organize.write", "write"],
102
112
  create_comment: ["comments", "comments.write", "write"],
113
+ create_custom_property: ["docs", "docs.properties", "docs.write", "write"],
103
114
  create_doc: ["docs", "docs.write", "write"],
104
115
  create_doc_from_markdown: ["docs", "docs.markdown", "docs.write", "write"],
105
116
  create_folder: ["organize", "organize.folders", "organize.write", "experimental", "write"],
@@ -112,6 +123,7 @@ const TOOL_GROUPS = {
112
123
  delete_block: ["docs", "docs.edgeless", "docs.write", "destructive", "write"],
113
124
  delete_collection: ["organize", "organize.collections", "organize.write", "destructive", "write"],
114
125
  delete_comment: ["comments", "comments.write", "destructive", "write"],
126
+ delete_custom_property: ["docs", "docs.properties", "docs.write", "destructive", "write"],
115
127
  delete_database_row: ["docs", "docs.database", "docs.write", "destructive", "write"],
116
128
  delete_doc: ["docs", "docs.write", "destructive", "write"],
117
129
  delete_folder: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
@@ -125,7 +137,9 @@ const TOOL_GROUPS = {
125
137
  get_capabilities: ["docs", "docs.read", "read"],
126
138
  get_collection: ["organize", "organize.collections", "organize.read", "read"],
127
139
  get_doc: ["docs", "docs.read", "read"],
140
+ get_doc_icon: ["docs", "docs.read", "read"],
128
141
  get_edgeless_canvas: ["docs", "docs.edgeless", "docs.surface", "docs.read", "read"],
142
+ get_folder_icon: ["organize", "organize.folders", "organize.read", "experimental", "read"],
129
143
  get_orphan_docs: ["docs", "docs.tree", "docs.read", "read"],
130
144
  get_workspace: ["workspaces", "workspaces.read", "read"],
131
145
  inspect_template_structure: ["docs", "docs.template", "docs.read", "read"],
@@ -134,6 +148,7 @@ const TOOL_GROUPS = {
134
148
  list_children: ["docs", "docs.tree", "docs.read", "read"],
135
149
  list_collections: ["organize", "organize.collections", "organize.read", "read"],
136
150
  list_comments: ["comments", "comments.read", "read"],
151
+ list_doc_properties: ["docs", "docs.properties", "docs.read", "read"],
137
152
  list_docs: ["docs", "docs.read", "read"],
138
153
  list_docs_by_tag: ["docs", "docs.tags", "docs.read", "read"],
139
154
  list_histories: ["history", "history.read", "read"],
@@ -158,13 +173,16 @@ const TOOL_GROUPS = {
158
173
  revoke_access_token: ["access_tokens", "access_tokens.write", "admin", "destructive", "write"],
159
174
  revoke_doc: ["docs", "docs.share", "docs.write", "destructive", "write"],
160
175
  search_docs: ["docs", "docs.read", "read"],
176
+ set_doc_property: ["docs", "docs.properties", "docs.write", "write"],
161
177
  sign_in: ["users", "users.auth", "auth", "write"],
162
178
  update_collection: ["organize", "organize.collections", "organize.write", "write"],
163
179
  update_collection_rules: ["organize", "organize.collections", "organize.write", "write"],
164
180
  update_comment: ["comments", "comments.write", "write"],
165
181
  update_database_row: ["docs", "docs.database", "docs.write", "write"],
182
+ update_doc_icon: ["docs", "docs.write", "write"],
166
183
  update_doc_title: ["docs", "docs.write", "write"],
167
184
  update_edgeless_block: ["docs", "docs.edgeless", "docs.write", "write"],
185
+ update_folder_icon: ["organize", "organize.folders", "organize.write", "experimental", "write"],
168
186
  update_frame_children: ["docs", "docs.edgeless", "docs.write", "write"],
169
187
  update_profile: ["users", "users.write", "admin", "write"],
170
188
  update_settings: ["users", "users.write", "admin", "write"],
@@ -181,7 +199,9 @@ const READ_ONLY_TOOLS = new Set([
181
199
  "get_capabilities",
182
200
  "get_collection",
183
201
  "get_doc",
202
+ "get_doc_icon",
184
203
  "get_edgeless_canvas",
204
+ "get_folder_icon",
185
205
  "get_orphan_docs",
186
206
  "get_workspace",
187
207
  "inspect_template_structure",
@@ -189,6 +209,7 @@ const READ_ONLY_TOOLS = new Set([
189
209
  "list_children",
190
210
  "list_collections",
191
211
  "list_comments",
212
+ "list_doc_properties",
192
213
  "list_docs",
193
214
  "list_docs_by_tag",
194
215
  "list_histories",
@@ -217,6 +238,7 @@ const CORE_TOOLS = new Set([
217
238
  "find_doc_by_title",
218
239
  "get_capabilities",
219
240
  "get_doc",
241
+ "get_doc_icon",
220
242
  "get_workspace",
221
243
  "list_children",
222
244
  "list_docs",
@@ -231,6 +253,7 @@ const CORE_TOOLS = new Set([
231
253
  "search_docs",
232
254
  "sign_in",
233
255
  "update_database_row",
256
+ "update_doc_icon",
234
257
  "update_doc_title",
235
258
  ]);
236
259
  const AUTHORING_EXCLUDED_GROUPS = new Set([
@@ -1,5 +1,5 @@
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";
@@ -7,6 +7,53 @@ import { parseMarkdownToOperations } from "../markdown/parse.js";
7
7
  import { renderBlocksToMarkdown } from "../markdown/render.js";
8
8
  import { addOrganizeLinkToFolder } from "./organize.js";
9
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";
10
+ function collectLinkedChildIds(blocks) {
11
+ const databaseRowIds = new Set();
12
+ for (const [, raw] of blocks) {
13
+ if (!(raw instanceof Y.Map))
14
+ continue;
15
+ if (raw.get("sys:flavour") !== "affine:database")
16
+ continue;
17
+ const rows = raw.get("sys:children");
18
+ if (rows instanceof Y.Array) {
19
+ rows.forEach((entry) => {
20
+ if (typeof entry === "string")
21
+ databaseRowIds.add(entry);
22
+ else if (Array.isArray(entry)) {
23
+ for (const child of entry)
24
+ if (typeof child === "string")
25
+ databaseRowIds.add(child);
26
+ }
27
+ });
28
+ }
29
+ }
30
+ const ids = [];
31
+ for (const [blockId, raw] of blocks) {
32
+ if (!(raw instanceof Y.Map))
33
+ continue;
34
+ const flavour = raw.get("sys:flavour");
35
+ if (flavour === "affine:embed-linked-doc" || flavour === "affine:embed-synced-doc") {
36
+ const pid = raw.get("prop:pageId");
37
+ if (typeof pid === "string" && pid)
38
+ ids.push(pid);
39
+ }
40
+ if (databaseRowIds.has(String(blockId)))
41
+ continue;
42
+ const propText = raw.get("prop:text");
43
+ if (propText instanceof Y.Text) {
44
+ const delta = propText.toDelta();
45
+ if (Array.isArray(delta)) {
46
+ for (const d of delta) {
47
+ const reference = d.attributes?.reference;
48
+ if (reference?.type === "LinkedPage" && typeof reference.pageId === "string" && reference.pageId) {
49
+ ids.push(reference.pageId);
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }
55
+ return ids;
56
+ }
10
57
  const WorkspaceId = z.string().min(1, "workspaceId required");
11
58
  const DocId = z.string().min(1, "docId required");
12
59
  const MarkdownContent = z.string().min(1, "markdown required");
@@ -166,22 +213,30 @@ export function registerDocTools(server, gql, defaults) {
166
213
  return makeText(delta);
167
214
  }
168
215
  /**
169
- * Extract a linked-doc page ID from a database row block's prop:text,
170
- * if it contains a LinkedPage reference delta. Returns null otherwise.
216
+ * Extract inline LinkedPage reference IDs from a Y.Text value. AFFiNE stores
217
+ * @-mentions as zero-width text deltas whose page id lives in attributes.
171
218
  */
172
- function readLinkedDocId(rowBlock) {
173
- const propText = rowBlock.get("prop:text");
219
+ function extractLinkedPageRefs(propText) {
174
220
  if (!(propText instanceof Y.Text))
175
- return null;
221
+ return [];
176
222
  const delta = propText.toDelta();
177
223
  if (!Array.isArray(delta))
178
- return null;
224
+ return [];
225
+ const refs = [];
179
226
  for (const d of delta) {
180
- if (d.attributes?.reference?.type === "LinkedPage" && d.attributes.reference.pageId) {
181
- return d.attributes.reference.pageId;
227
+ const reference = d.attributes?.reference;
228
+ if (reference?.type === "LinkedPage" && typeof reference.pageId === "string" && reference.pageId.length > 0) {
229
+ refs.push(reference.pageId);
182
230
  }
183
231
  }
184
- return null;
232
+ return refs;
233
+ }
234
+ /**
235
+ * Extract a linked-doc page ID from a database row block's prop:text,
236
+ * if it contains a LinkedPage reference delta. Returns null otherwise.
237
+ */
238
+ function readLinkedDocId(rowBlock) {
239
+ return extractLinkedPageRefs(rowBlock.get("prop:text"))[0] ?? null;
185
240
  }
186
241
  function asText(value) {
187
242
  if (value instanceof Y.Text)
@@ -1340,16 +1395,23 @@ export function registerDocTools(server, gql, defaults) {
1340
1395
  const rowIds = [];
1341
1396
  const columnIds = [];
1342
1397
  const tableData = normalized.tableData ?? [];
1398
+ // Row/column `order` must be valid fractional-indexing keys. AFFiNE's
1399
+ // editor computes the next order with generateKeyBetween(prevOrder, ...)
1400
+ // when inserting a row/column; plain strings like "r0000" render but make
1401
+ // that call throw "invalid order key", so rows/columns cannot be added in
1402
+ // the UI. Use real keys (a0, a1, ...) so insertion works.
1403
+ const rowOrders = generateNKeysBetween(null, null, normalized.rows);
1404
+ const columnOrders = generateNKeysBetween(null, null, normalized.columns);
1343
1405
  for (let i = 0; i < normalized.rows; i++) {
1344
1406
  const rowId = generateId();
1345
1407
  block.set(`prop:rows.${rowId}.rowId`, rowId);
1346
- block.set(`prop:rows.${rowId}.order`, `r${String(i).padStart(4, "0")}`);
1408
+ block.set(`prop:rows.${rowId}.order`, rowOrders[i]);
1347
1409
  rowIds.push(rowId);
1348
1410
  }
1349
1411
  for (let i = 0; i < normalized.columns; i++) {
1350
1412
  const columnId = generateId();
1351
1413
  block.set(`prop:columns.${columnId}.columnId`, columnId);
1352
- block.set(`prop:columns.${columnId}.order`, `c${String(i).padStart(4, "0")}`);
1414
+ block.set(`prop:columns.${columnId}.order`, columnOrders[i]);
1353
1415
  columnIds.push(columnId);
1354
1416
  }
1355
1417
  for (let rowIndex = 0; rowIndex < rowIds.length; rowIndex += 1) {
@@ -2074,6 +2136,13 @@ export function registerDocTools(server, gql, defaults) {
2074
2136
  return [];
2075
2137
  }
2076
2138
  function extractTableData(block) {
2139
+ const compareOrder = (left, right) => {
2140
+ if (left < right)
2141
+ return -1;
2142
+ if (left > right)
2143
+ return 1;
2144
+ return 0;
2145
+ };
2077
2146
  const rowsValue = block.get("prop:rows");
2078
2147
  const columnsValue = block.get("prop:columns");
2079
2148
  const cellsValue = block.get("prop:cells");
@@ -2084,7 +2153,7 @@ export function registerDocTools(server, gql, defaults) {
2084
2153
  ? payload.order
2085
2154
  : rowId,
2086
2155
  }))
2087
- .sort((a, b) => a.order.localeCompare(b.order));
2156
+ .sort((a, b) => compareOrder(a.order, b.order));
2088
2157
  let columnEntries = mapEntries(columnsValue)
2089
2158
  .map(([columnId, payload]) => ({
2090
2159
  columnId,
@@ -2092,7 +2161,7 @@ export function registerDocTools(server, gql, defaults) {
2092
2161
  ? payload.order
2093
2162
  : columnId,
2094
2163
  }))
2095
- .sort((a, b) => a.order.localeCompare(b.order));
2164
+ .sort((a, b) => compareOrder(a.order, b.order));
2096
2165
  let cells = new Map();
2097
2166
  if (rowEntries.length === 0 || columnEntries.length === 0) {
2098
2167
  // Fallback: AFFiNE self-hosted stores table props as flat dot-notation keys
@@ -2122,10 +2191,10 @@ export function registerDocTools(server, gql, defaults) {
2122
2191
  if (flatRows.size > 0 && flatColumns.size > 0) {
2123
2192
  rowEntries = Array.from(flatRows.entries())
2124
2193
  .map(([rowId, order]) => ({ rowId, order }))
2125
- .sort((a, b) => a.order.localeCompare(b.order));
2194
+ .sort((a, b) => compareOrder(a.order, b.order));
2126
2195
  columnEntries = Array.from(flatColumns.entries())
2127
2196
  .map(([columnId, order]) => ({ columnId, order }))
2128
- .sort((a, b) => a.order.localeCompare(b.order));
2197
+ .sort((a, b) => compareOrder(a.order, b.order));
2129
2198
  cells = flatCells;
2130
2199
  }
2131
2200
  }
@@ -3788,7 +3857,9 @@ export function registerDocTools(server, gql, defaults) {
3788
3857
  const flavour = raw.get("sys:flavour");
3789
3858
  const parentId = raw.get("sys:parent");
3790
3859
  const type = raw.get("prop:type");
3791
- const textValue = asText(raw.get("prop:text"));
3860
+ const propText = raw.get("prop:text");
3861
+ const textValue = asText(propText);
3862
+ const linkedDocIds = extractLinkedPageRefs(propText);
3792
3863
  const language = raw.get("prop:language");
3793
3864
  const checked = raw.get("prop:checked");
3794
3865
  const childIds = childIdsFrom(raw.get("sys:children"));
@@ -3804,6 +3875,7 @@ export function registerDocTools(server, gql, defaults) {
3804
3875
  flavour: typeof flavour === "string" ? flavour : null,
3805
3876
  type: typeof type === "string" ? type : null,
3806
3877
  text: textValue.length > 0 ? textValue : null,
3878
+ linkedDocIds,
3807
3879
  checked: typeof checked === "boolean" ? checked : null,
3808
3880
  language: typeof language === "string" ? language : null,
3809
3881
  childIds,
@@ -4758,13 +4830,8 @@ export function registerDocTools(server, gql, defaults) {
4758
4830
  Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4759
4831
  const blocks = doc.getMap("blocks");
4760
4832
  const kids = [];
4761
- for (const [, raw] of blocks) {
4762
- if (!(raw instanceof Y.Map))
4763
- continue;
4764
- if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
4765
- continue;
4766
- const pid = raw.get("prop:pageId");
4767
- if (typeof pid === "string" && pid && titleById.has(pid)) {
4833
+ for (const pid of collectLinkedChildIds(blocks)) {
4834
+ if (titleById.has(pid) && !kids.includes(pid)) {
4768
4835
  kids.push(pid);
4769
4836
  allChildren.add(pid);
4770
4837
  }
@@ -4818,14 +4885,8 @@ export function registerDocTools(server, gql, defaults) {
4818
4885
  const doc = new Y.Doc();
4819
4886
  Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4820
4887
  const blocks = doc.getMap("blocks");
4821
- for (const [, raw] of blocks) {
4822
- if (!(raw instanceof Y.Map))
4823
- continue;
4824
- if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
4825
- continue;
4826
- const pageId = raw.get("prop:pageId");
4827
- if (typeof pageId === "string" && pageId)
4828
- allChildren.add(pageId);
4888
+ for (const pageId of collectLinkedChildIds(blocks)) {
4889
+ allChildren.add(pageId);
4829
4890
  }
4830
4891
  }
4831
4892
  const baseUrl = (process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, "")).replace(/\/$/, "");
@@ -4844,7 +4905,7 @@ export function registerDocTools(server, gql, defaults) {
4844
4905
  };
4845
4906
  server.registerTool("get_orphan_docs", {
4846
4907
  title: "Get Orphan Documents",
4847
- description: "Find all documents that have no parent (not linked from any other doc via embed_linked_doc). Useful for workspace hygiene. Note: scans all docs — O(n).",
4908
+ description: "Find all documents that have no parent (not linked from any other doc via embed_linked_doc / embed_synced_doc blocks or inline LinkedPage references). Useful for workspace hygiene. Note: scans all docs — O(n).",
4848
4909
  inputSchema: { workspaceId: z.string().optional() },
4849
4910
  }, getOrphanDocsHandler);
4850
4911
  const listChildrenHandler = async (parsed) => {
@@ -4857,11 +4918,15 @@ export function registerDocTools(server, gql, defaults) {
4857
4918
  try {
4858
4919
  await joinWorkspace(socket, workspaceId);
4859
4920
  const titleById = new Map();
4921
+ const workspacePageIds = new Set();
4922
+ let hasWorkspaceMetadata = false;
4860
4923
  const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
4861
4924
  if (wsSnap.missing) {
4925
+ hasWorkspaceMetadata = true;
4862
4926
  const wsDoc = new Y.Doc();
4863
4927
  Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
4864
4928
  for (const page of getWorkspacePageEntries(wsDoc.getMap("meta"))) {
4929
+ workspacePageIds.add(page.id);
4865
4930
  if (page.title)
4866
4931
  titleById.set(page.id, page.title);
4867
4932
  }
@@ -4873,16 +4938,15 @@ export function registerDocTools(server, gql, defaults) {
4873
4938
  Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4874
4939
  const blocks = doc.getMap("blocks");
4875
4940
  const children = [];
4876
- for (const [, raw] of blocks) {
4877
- if (!(raw instanceof Y.Map))
4941
+ const seen = new Set();
4942
+ for (const pageId of collectLinkedChildIds(blocks)) {
4943
+ if (hasWorkspaceMetadata && !workspacePageIds.has(pageId))
4878
4944
  continue;
4879
- if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
4945
+ if (seen.has(pageId))
4880
4946
  continue;
4881
- const pageId = raw.get("prop:pageId");
4882
- if (typeof pageId === "string" && pageId) {
4883
- children.push({ docId: pageId, title: titleById.get(pageId) ?? null,
4884
- url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}` });
4885
- }
4947
+ seen.add(pageId);
4948
+ children.push({ docId: pageId, title: titleById.get(pageId) ?? null,
4949
+ url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}` });
4886
4950
  }
4887
4951
  return text({ docId: parsed.docId, count: children.length, children });
4888
4952
  }
@@ -4892,7 +4956,7 @@ export function registerDocTools(server, gql, defaults) {
4892
4956
  };
4893
4957
  server.registerTool("list_children", {
4894
4958
  title: "List Document Children",
4895
- description: "List the direct children of a document in the sidebar (embed_linked_doc blocks). Returns docId, title, and URL for each child.",
4959
+ description: "List the direct children of a document in the sidebar (embed_linked_doc / embed_synced_doc blocks and inline LinkedPage references). Returns docId, title, and URL for each child.",
4896
4960
  inputSchema: {
4897
4961
  workspaceId: z.string().optional(),
4898
4962
  docId: z.string().describe("The parent doc whose children to list."),
@@ -0,0 +1,125 @@
1
+ import { z } from "zod";
2
+ import { receipt } from "../util/mcp.js";
3
+ import { connectWorkspaceSocket, joinWorkspace, wsUrlFromGraphQLEndpoint, } from "../ws.js";
4
+ import { docIconKey, folderIconKey, getExplorerIcon, normalizeIconInput, setExplorerIcon, } from "../util/explorerIcon.js";
5
+ /**
6
+ * Zod shape for the `icon` parameter shared by both setters. Accepts an emoji
7
+ * shorthand string, a full `{type:"emoji",unicode}` / `{type:"icon",name}`
8
+ * object, or `null` to clear the icon.
9
+ */
10
+ const iconSchema = z
11
+ .union([
12
+ z.string(),
13
+ z.object({ type: z.literal("emoji"), unicode: z.string() }),
14
+ z.object({ type: z.literal("icon"), name: z.string() }),
15
+ z.null(),
16
+ ])
17
+ .describe('Emoji shorthand ("🧪"), a full object ({type:"emoji",unicode:"🧪"} or ' +
18
+ '{type:"icon",name:"check"}), or null to remove the icon.');
19
+ /**
20
+ * Registers the explorer-icon tools: set/clear and read the Notion-style
21
+ * sidebar icon on a document or an organize folder. All four share the
22
+ * `explorerIcon` sub-doc helper so the storage model lives in one place.
23
+ */
24
+ export function registerIconTools(server, gql, defaults) {
25
+ function resolveWorkspaceId(workspaceId) {
26
+ const resolved = workspaceId || defaults.workspaceId;
27
+ if (!resolved) {
28
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID.");
29
+ }
30
+ return resolved;
31
+ }
32
+ async function withSocket(workspaceId, fn) {
33
+ const wsUrl = wsUrlFromGraphQLEndpoint(gql.endpoint);
34
+ const socket = await connectWorkspaceSocket(wsUrl, gql.cookie, gql.bearer);
35
+ try {
36
+ await joinWorkspace(socket, workspaceId);
37
+ return await fn(socket);
38
+ }
39
+ finally {
40
+ socket.disconnect();
41
+ }
42
+ }
43
+ // ─── update_doc_icon ────────────────────────────────────────────────────────
44
+ const updateDocIconHandler = async (parsed) => {
45
+ const workspaceId = resolveWorkspaceId(parsed.workspaceId);
46
+ const icon = normalizeIconInput(parsed.icon);
47
+ const result = await withSocket(workspaceId, (socket) => setExplorerIcon(socket, workspaceId, docIconKey(parsed.docId), icon));
48
+ return receipt("doc.update_icon", {
49
+ workspaceId,
50
+ docId: parsed.docId,
51
+ icon: result.icon,
52
+ cleared: result.icon === null,
53
+ });
54
+ };
55
+ server.registerTool("update_doc_icon", {
56
+ title: "Update Document Icon",
57
+ description: "Set or clear the sidebar icon (the Notion-style emoji slot) on a document. " +
58
+ "Pass an emoji string, a full icon object, or null to remove it.",
59
+ inputSchema: {
60
+ workspaceId: z.string().optional(),
61
+ docId: z.string().describe("The document whose icon to update."),
62
+ icon: iconSchema,
63
+ },
64
+ }, updateDocIconHandler);
65
+ // ─── update_folder_icon ─────────────────────────────────────────────────────
66
+ const updateFolderIconHandler = async (parsed) => {
67
+ const workspaceId = resolveWorkspaceId(parsed.workspaceId);
68
+ const icon = normalizeIconInput(parsed.icon);
69
+ const result = await withSocket(workspaceId, (socket) => setExplorerIcon(socket, workspaceId, folderIconKey(parsed.folderId), icon));
70
+ return receipt("folder.update_icon", {
71
+ workspaceId,
72
+ folderId: parsed.folderId,
73
+ icon: result.icon,
74
+ cleared: result.icon === null,
75
+ });
76
+ };
77
+ server.registerTool("update_folder_icon", {
78
+ title: "Update Folder Icon",
79
+ description: "Set or clear the sidebar icon on an organize folder. " +
80
+ "Pass an emoji string, a full icon object, or null to remove it. Experimental.",
81
+ inputSchema: {
82
+ workspaceId: z.string().optional(),
83
+ folderId: z.string().describe("The organize folder whose icon to update."),
84
+ icon: iconSchema,
85
+ },
86
+ }, updateFolderIconHandler);
87
+ // ─── get_doc_icon ───────────────────────────────────────────────────────────
88
+ const getDocIconHandler = async (parsed) => {
89
+ const workspaceId = resolveWorkspaceId(parsed.workspaceId);
90
+ const result = await withSocket(workspaceId, (socket) => getExplorerIcon(socket, workspaceId, docIconKey(parsed.docId)));
91
+ return receipt("doc.get_icon", {
92
+ workspaceId,
93
+ docId: parsed.docId,
94
+ icon: result.icon,
95
+ hasIcon: result.icon !== null,
96
+ });
97
+ };
98
+ server.registerTool("get_doc_icon", {
99
+ title: "Get Document Icon",
100
+ description: "Read the current sidebar icon of a document. Returns null when none is set.",
101
+ inputSchema: {
102
+ workspaceId: z.string().optional(),
103
+ docId: z.string().describe("The document whose icon to read."),
104
+ },
105
+ }, getDocIconHandler);
106
+ // ─── get_folder_icon ────────────────────────────────────────────────────────
107
+ const getFolderIconHandler = async (parsed) => {
108
+ const workspaceId = resolveWorkspaceId(parsed.workspaceId);
109
+ const result = await withSocket(workspaceId, (socket) => getExplorerIcon(socket, workspaceId, folderIconKey(parsed.folderId)));
110
+ return receipt("folder.get_icon", {
111
+ workspaceId,
112
+ folderId: parsed.folderId,
113
+ icon: result.icon,
114
+ hasIcon: result.icon !== null,
115
+ });
116
+ };
117
+ server.registerTool("get_folder_icon", {
118
+ title: "Get Folder Icon",
119
+ description: "Read the current sidebar icon of an organize folder. Returns null when none is set. Experimental.",
120
+ inputSchema: {
121
+ workspaceId: z.string().optional(),
122
+ folderId: z.string().describe("The organize folder whose icon to read."),
123
+ },
124
+ }, getFolderIconHandler);
125
+ }
@@ -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
+ }
@@ -0,0 +1,95 @@
1
+ import * as Y from "yjs";
2
+ import { loadDoc, pushDocUpdate } from "../ws.js";
3
+ /** Build the sub-document guid that holds a workspace's explorer icons. */
4
+ export function explorerIconDocId(workspaceId) {
5
+ return `db$${workspaceId}$explorerIcon`;
6
+ }
7
+ /** Build the per-entity map key for a document. */
8
+ export function docIconKey(docId) {
9
+ return `doc:${docId}`;
10
+ }
11
+ /** Build the per-entity map key for an organize folder. */
12
+ export function folderIconKey(folderId) {
13
+ return `folder:${folderId}`;
14
+ }
15
+ /**
16
+ * Coerce user input into the stored icon shape (or null to clear).
17
+ *
18
+ * - A bare string is treated as an emoji shorthand → `{ type: "emoji", unicode }`.
19
+ * - A `{ type: "emoji", unicode }` or `{ type: "icon", name }` object is passed
20
+ * through after validation. Named-icon `name` values are not validated against
21
+ * AFFiNE's fixed icon set — they are written as-is.
22
+ * - `null` clears the icon.
23
+ */
24
+ export function normalizeIconInput(input) {
25
+ if (input === null)
26
+ return null;
27
+ if (typeof input === "string") {
28
+ const unicode = input.trim();
29
+ if (!unicode)
30
+ throw new Error("icon emoji string must not be empty.");
31
+ return { type: "emoji", unicode };
32
+ }
33
+ if (input.type === "emoji") {
34
+ const unicode = input.unicode?.trim();
35
+ if (!unicode)
36
+ throw new Error("emoji icon requires a non-empty `unicode`.");
37
+ return { type: "emoji", unicode };
38
+ }
39
+ if (input.type === "icon") {
40
+ const name = input.name?.trim();
41
+ if (!name)
42
+ throw new Error("named icon requires a non-empty `name`.");
43
+ return { type: "icon", name };
44
+ }
45
+ throw new Error(`Unsupported icon type: ${JSON.stringify(input.type)}.`);
46
+ }
47
+ async function loadExplorerIconDoc(socket, workspaceId) {
48
+ const snap = await loadDoc(socket, workspaceId, explorerIconDocId(workspaceId));
49
+ const doc = new Y.Doc();
50
+ if (snap.missing)
51
+ Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
52
+ return doc;
53
+ }
54
+ function readIcon(doc, key) {
55
+ const map = doc.share.has(key) ? doc.getMap(key) : null;
56
+ if (!map || !map.has("icon"))
57
+ return null;
58
+ const raw = map.get("icon");
59
+ if (raw && typeof raw.toJSON === "function") {
60
+ return raw.toJSON();
61
+ }
62
+ return raw ?? null;
63
+ }
64
+ /**
65
+ * Set or clear the sidebar icon for a single explorer entity.
66
+ *
67
+ * Loads the workspace's `explorerIcon` sub-doc, mutates only the target entry,
68
+ * and pushes the minimal delta. Passing `icon = null` removes the `icon` field
69
+ * while preserving the entry's `id`.
70
+ */
71
+ export async function setExplorerIcon(socket, workspaceId, key, icon) {
72
+ const doc = await loadExplorerIconDoc(socket, workspaceId);
73
+ const prevSV = Y.encodeStateVector(doc);
74
+ const map = doc.getMap(key);
75
+ if (!map.has("id"))
76
+ map.set("id", key);
77
+ if (icon === null) {
78
+ if (map.has("icon"))
79
+ map.delete("icon");
80
+ }
81
+ else {
82
+ map.set("icon", icon);
83
+ }
84
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
85
+ await pushDocUpdate(socket, workspaceId, explorerIconDocId(workspaceId), Buffer.from(delta).toString("base64"));
86
+ return { key, icon };
87
+ }
88
+ /**
89
+ * Read the current sidebar icon for a single explorer entity.
90
+ * Returns `null` when no icon is set (or the entry does not exist).
91
+ */
92
+ export async function getExplorerIcon(socket, workspaceId, key) {
93
+ const doc = await loadExplorerIconDoc(socket, workspaceId);
94
+ return { key, icon: readIcon(doc, key) };
95
+ }
@@ -39,6 +39,8 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
39
39
  | `create_folder` | Create a root or nested folder | Experimental |
40
40
  | `create_workspace_blueprint` | Create a simple workspace folder blueprint | Good for structured onboarding setups |
41
41
  | `rename_folder` | Rename a folder | Experimental |
42
+ | `update_folder_icon` | Set or clear a folder's sidebar icon (emoji or named icon) | Experimental |
43
+ | `get_folder_icon` | Read a folder's current sidebar icon | Experimental |
42
44
  | `delete_folder` | Delete a folder recursively | Experimental and destructive |
43
45
  | `move_organize_node` | Move a folder or link node | Experimental |
44
46
  | `add_organize_link` | Add a doc, tag, or collection link under a folder | Experimental |
@@ -55,7 +57,7 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
55
57
  | `search_docs` | Search titles with substring, prefix, or exact matching | Supports tag filter and updatedAt sorting |
56
58
  | `list_docs_by_tag` | List documents with a specific tag | |
57
59
  | `get_doc` | Read document metadata | |
58
- | `read_doc` | Read block content and plain text snapshot | WebSocket-backed |
60
+ | `read_doc` | Read block content and plain text snapshot | WebSocket-backed; block rows include `linkedDocIds` for inline LinkedPage references |
59
61
  | `get_capabilities` | Inspect the server's high-level authoring and fidelity capabilities | Useful for adaptive clients |
60
62
  | `analyze_doc_fidelity` | Analyze how a document maps to Markdown and which native AFFiNE structures are lossy | Good before export or migration |
61
63
  | `list_children` | List direct child docs linked from a document | |
@@ -83,6 +85,8 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
83
85
  | Tool | Purpose | Notes |
84
86
  | --- | --- | --- |
85
87
  | `update_doc_title` | Rename a document in workspace metadata and in the page block | |
88
+ | `update_doc_icon` | Set or clear a document's sidebar icon (emoji or named icon) | |
89
+ | `get_doc_icon` | Read a document's current sidebar icon | |
86
90
  | `append_block` | Append canonical block types with validation and placement control | Supports text, media, embeds, database, and edgeless blocks. `frame`/`edgeless_text`/`note` accept `x`/`y`/`width`/`height`. `note` with `text` auto-creates a child paragraph so it renders on the edgeless canvas. |
87
91
  | `create_semantic_page` | Create an AFFiNE-native page with an intentional section skeleton and native block composition | High-level authoring helper |
88
92
  | `append_semantic_section` | Append a semantic section to an existing page by heading title | High-level authoring helper |
@@ -97,6 +101,16 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
97
101
  | `add_tag_to_doc` | Attach a tag to a document | |
98
102
  | `remove_tag_from_doc` | Detach a tag from a document | |
99
103
 
104
+ ### Custom properties
105
+
106
+ | Tool | Purpose | Notes |
107
+ | --- | --- | --- |
108
+ | `list_doc_properties` | List workspace custom-property definitions and a document's current values | WebSocket-backed; reads the `db$docProperties` / `db$docCustomPropertyInfo` sub-docs |
109
+ | `create_custom_property` | Create a workspace-wide custom property definition | Types: `text`, `number`, `checkbox`, `date`. Returns the `propertyId` |
110
+ | `delete_custom_property` | Soft-delete a custom property definition by id or name | Destructive; existing values are hidden |
111
+ | `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`) |
112
+ | `clear_doc_property` | Remove a custom property value from a document | |
113
+
100
114
  ### Markdown export
101
115
 
102
116
  | Tool | Purpose | Notes |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "affine-mcp-server",
3
- "version": "2.1.0",
3
+ "version": "2.3.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.",
@@ -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:icons": "node tests/test-icons.mjs",
53
+ "test:read-doc-linked-refs": "node tests/test-read-doc-linked-refs.mjs",
50
54
  "test:find-doc-by-title": "node tests/test-find-doc-by-title.mjs",
51
55
  "test:create-placement": "node tests/test-create-placement.mjs",
52
56
  "test:surface-elements": "node tests/test-surface-elements.mjs",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.1.0",
2
+ "version": "2.3.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",
@@ -40,7 +43,9 @@
40
43
  "get_capabilities",
41
44
  "get_collection",
42
45
  "get_doc",
46
+ "get_doc_icon",
43
47
  "get_edgeless_canvas",
48
+ "get_folder_icon",
44
49
  "get_orphan_docs",
45
50
  "get_workspace",
46
51
  "inspect_template_structure",
@@ -49,6 +54,7 @@
49
54
  "list_children",
50
55
  "list_collections",
51
56
  "list_comments",
57
+ "list_doc_properties",
52
58
  "list_docs",
53
59
  "list_docs_by_tag",
54
60
  "list_histories",
@@ -73,13 +79,16 @@
73
79
  "revoke_access_token",
74
80
  "revoke_doc",
75
81
  "search_docs",
82
+ "set_doc_property",
76
83
  "sign_in",
77
84
  "update_collection",
78
85
  "update_collection_rules",
79
86
  "update_comment",
80
87
  "update_database_row",
88
+ "update_doc_icon",
81
89
  "update_doc_title",
82
90
  "update_edgeless_block",
91
+ "update_folder_icon",
83
92
  "update_frame_children",
84
93
  "update_profile",
85
94
  "update_settings",