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