affine-mcp-server 2.1.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.1.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 85 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.1.0: Added exact-title document lookup, folder placement for `create_doc`, and trusted npm publishing.
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",
@@ -47,6 +50,7 @@ const ALL_TOOLS = [
47
50
  "list_children",
48
51
  "list_collections",
49
52
  "list_comments",
53
+ "list_doc_properties",
50
54
  "list_docs",
51
55
  "list_docs_by_tag",
52
56
  "list_histories",
@@ -71,6 +75,7 @@ const ALL_TOOLS = [
71
75
  "revoke_access_token",
72
76
  "revoke_doc",
73
77
  "search_docs",
78
+ "set_doc_property",
74
79
  "sign_in",
75
80
  "update_collection",
76
81
  "update_collection_rules",
@@ -97,9 +102,11 @@ const TOOL_GROUPS = {
97
102
  append_markdown: ["docs", "docs.markdown", "docs.write", "write"],
98
103
  append_semantic_section: ["docs", "docs.semantic", "docs.write", "write"],
99
104
  cleanup_blobs: ["blobs", "blobs.write", "cleanup", "destructive", "write"],
105
+ clear_doc_property: ["docs", "docs.properties", "docs.write", "write"],
100
106
  compose_database_from_intent: ["docs", "docs.database", "docs.intent", "docs.write", "write"],
101
107
  create_collection: ["organize", "organize.collections", "organize.write", "write"],
102
108
  create_comment: ["comments", "comments.write", "write"],
109
+ create_custom_property: ["docs", "docs.properties", "docs.write", "write"],
103
110
  create_doc: ["docs", "docs.write", "write"],
104
111
  create_doc_from_markdown: ["docs", "docs.markdown", "docs.write", "write"],
105
112
  create_folder: ["organize", "organize.folders", "organize.write", "experimental", "write"],
@@ -112,6 +119,7 @@ const TOOL_GROUPS = {
112
119
  delete_block: ["docs", "docs.edgeless", "docs.write", "destructive", "write"],
113
120
  delete_collection: ["organize", "organize.collections", "organize.write", "destructive", "write"],
114
121
  delete_comment: ["comments", "comments.write", "destructive", "write"],
122
+ delete_custom_property: ["docs", "docs.properties", "docs.write", "destructive", "write"],
115
123
  delete_database_row: ["docs", "docs.database", "docs.write", "destructive", "write"],
116
124
  delete_doc: ["docs", "docs.write", "destructive", "write"],
117
125
  delete_folder: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
@@ -134,6 +142,7 @@ const TOOL_GROUPS = {
134
142
  list_children: ["docs", "docs.tree", "docs.read", "read"],
135
143
  list_collections: ["organize", "organize.collections", "organize.read", "read"],
136
144
  list_comments: ["comments", "comments.read", "read"],
145
+ list_doc_properties: ["docs", "docs.properties", "docs.read", "read"],
137
146
  list_docs: ["docs", "docs.read", "read"],
138
147
  list_docs_by_tag: ["docs", "docs.tags", "docs.read", "read"],
139
148
  list_histories: ["history", "history.read", "read"],
@@ -158,6 +167,7 @@ const TOOL_GROUPS = {
158
167
  revoke_access_token: ["access_tokens", "access_tokens.write", "admin", "destructive", "write"],
159
168
  revoke_doc: ["docs", "docs.share", "docs.write", "destructive", "write"],
160
169
  search_docs: ["docs", "docs.read", "read"],
170
+ set_doc_property: ["docs", "docs.properties", "docs.write", "write"],
161
171
  sign_in: ["users", "users.auth", "auth", "write"],
162
172
  update_collection: ["organize", "organize.collections", "organize.write", "write"],
163
173
  update_collection_rules: ["organize", "organize.collections", "organize.write", "write"],
@@ -189,6 +199,7 @@ const READ_ONLY_TOOLS = new Set([
189
199
  "list_children",
190
200
  "list_collections",
191
201
  "list_comments",
202
+ "list_doc_properties",
192
203
  "list_docs",
193
204
  "list_docs_by_tag",
194
205
  "list_histories",
@@ -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";
@@ -166,22 +166,30 @@ export function registerDocTools(server, gql, defaults) {
166
166
  return makeText(delta);
167
167
  }
168
168
  /**
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.
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.
171
171
  */
172
- function readLinkedDocId(rowBlock) {
173
- const propText = rowBlock.get("prop:text");
172
+ function extractLinkedPageRefs(propText) {
174
173
  if (!(propText instanceof Y.Text))
175
- return null;
174
+ return [];
176
175
  const delta = propText.toDelta();
177
176
  if (!Array.isArray(delta))
178
- return null;
177
+ return [];
178
+ const refs = [];
179
179
  for (const d of delta) {
180
- if (d.attributes?.reference?.type === "LinkedPage" && d.attributes.reference.pageId) {
181
- 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);
182
183
  }
183
184
  }
184
- 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;
185
193
  }
186
194
  function asText(value) {
187
195
  if (value instanceof Y.Text)
@@ -1340,16 +1348,23 @@ export function registerDocTools(server, gql, defaults) {
1340
1348
  const rowIds = [];
1341
1349
  const columnIds = [];
1342
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);
1343
1358
  for (let i = 0; i < normalized.rows; i++) {
1344
1359
  const rowId = generateId();
1345
1360
  block.set(`prop:rows.${rowId}.rowId`, rowId);
1346
- block.set(`prop:rows.${rowId}.order`, `r${String(i).padStart(4, "0")}`);
1361
+ block.set(`prop:rows.${rowId}.order`, rowOrders[i]);
1347
1362
  rowIds.push(rowId);
1348
1363
  }
1349
1364
  for (let i = 0; i < normalized.columns; i++) {
1350
1365
  const columnId = generateId();
1351
1366
  block.set(`prop:columns.${columnId}.columnId`, columnId);
1352
- block.set(`prop:columns.${columnId}.order`, `c${String(i).padStart(4, "0")}`);
1367
+ block.set(`prop:columns.${columnId}.order`, columnOrders[i]);
1353
1368
  columnIds.push(columnId);
1354
1369
  }
1355
1370
  for (let rowIndex = 0; rowIndex < rowIds.length; rowIndex += 1) {
@@ -2074,6 +2089,13 @@ export function registerDocTools(server, gql, defaults) {
2074
2089
  return [];
2075
2090
  }
2076
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
+ };
2077
2099
  const rowsValue = block.get("prop:rows");
2078
2100
  const columnsValue = block.get("prop:columns");
2079
2101
  const cellsValue = block.get("prop:cells");
@@ -2084,7 +2106,7 @@ export function registerDocTools(server, gql, defaults) {
2084
2106
  ? payload.order
2085
2107
  : rowId,
2086
2108
  }))
2087
- .sort((a, b) => a.order.localeCompare(b.order));
2109
+ .sort((a, b) => compareOrder(a.order, b.order));
2088
2110
  let columnEntries = mapEntries(columnsValue)
2089
2111
  .map(([columnId, payload]) => ({
2090
2112
  columnId,
@@ -2092,7 +2114,7 @@ export function registerDocTools(server, gql, defaults) {
2092
2114
  ? payload.order
2093
2115
  : columnId,
2094
2116
  }))
2095
- .sort((a, b) => a.order.localeCompare(b.order));
2117
+ .sort((a, b) => compareOrder(a.order, b.order));
2096
2118
  let cells = new Map();
2097
2119
  if (rowEntries.length === 0 || columnEntries.length === 0) {
2098
2120
  // Fallback: AFFiNE self-hosted stores table props as flat dot-notation keys
@@ -2122,10 +2144,10 @@ export function registerDocTools(server, gql, defaults) {
2122
2144
  if (flatRows.size > 0 && flatColumns.size > 0) {
2123
2145
  rowEntries = Array.from(flatRows.entries())
2124
2146
  .map(([rowId, order]) => ({ rowId, order }))
2125
- .sort((a, b) => a.order.localeCompare(b.order));
2147
+ .sort((a, b) => compareOrder(a.order, b.order));
2126
2148
  columnEntries = Array.from(flatColumns.entries())
2127
2149
  .map(([columnId, order]) => ({ columnId, order }))
2128
- .sort((a, b) => a.order.localeCompare(b.order));
2150
+ .sort((a, b) => compareOrder(a.order, b.order));
2129
2151
  cells = flatCells;
2130
2152
  }
2131
2153
  }
@@ -3788,7 +3810,9 @@ export function registerDocTools(server, gql, defaults) {
3788
3810
  const flavour = raw.get("sys:flavour");
3789
3811
  const parentId = raw.get("sys:parent");
3790
3812
  const type = raw.get("prop:type");
3791
- const textValue = asText(raw.get("prop:text"));
3813
+ const propText = raw.get("prop:text");
3814
+ const textValue = asText(propText);
3815
+ const linkedDocIds = extractLinkedPageRefs(propText);
3792
3816
  const language = raw.get("prop:language");
3793
3817
  const checked = raw.get("prop:checked");
3794
3818
  const childIds = childIdsFrom(raw.get("sys:children"));
@@ -3804,6 +3828,7 @@ export function registerDocTools(server, gql, defaults) {
3804
3828
  flavour: typeof flavour === "string" ? flavour : null,
3805
3829
  type: typeof type === "string" ? type : null,
3806
3830
  text: textValue.length > 0 ? textValue : null,
3831
+ linkedDocIds,
3807
3832
  checked: typeof checked === "boolean" ? checked : null,
3808
3833
  language: typeof language === "string" ? language : null,
3809
3834
  childIds,
@@ -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.1.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.",
@@ -43,10 +43,13 @@
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",
50
53
  "test:find-doc-by-title": "node tests/test-find-doc-by-title.mjs",
51
54
  "test:create-placement": "node tests/test-create-placement.mjs",
52
55
  "test:surface-elements": "node tests/test-surface-elements.mjs",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.1.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",
@@ -49,6 +52,7 @@
49
52
  "list_children",
50
53
  "list_collections",
51
54
  "list_comments",
55
+ "list_doc_properties",
52
56
  "list_docs",
53
57
  "list_docs_by_tag",
54
58
  "list_histories",
@@ -73,6 +77,7 @@
73
77
  "revoke_access_token",
74
78
  "revoke_doc",
75
79
  "search_docs",
80
+ "set_doc_property",
76
81
  "sign_in",
77
82
  "update_collection",
78
83
  "update_collection_rules",