affine-mcp-server 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/index.js +4 -0
- package/dist/toolSurface.js +23 -0
- package/dist/tools/docs.js +106 -42
- package/dist/tools/icons.js +125 -0
- package/dist/tools/properties.js +426 -0
- package/dist/util/explorerIcon.js +95 -0
- package/docs/tool-reference.md +15 -1
- package/package.json +5 -1
- package/tool-manifest.json +10 -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 94 canonical MCP tools backed by AFFiNE GraphQL and WebSocket APIs
|
|
42
42
|
- Includes semantic page composition, native template instantiation, database intent composition, capability and fidelity reporting, and workspace blueprint helpers
|
|
43
43
|
- Includes Docker images, health probes, and end-to-end test coverage
|
|
44
44
|
|
|
@@ -48,7 +48,7 @@ Scope boundaries:
|
|
|
48
48
|
- Browser-local workspaces stored only in local storage are not available through AFFiNE server APIs
|
|
49
49
|
- AFFiNE Cloud requires API-token-based access for MCP usage; programmatic email/password sign-in is blocked by Cloudflare
|
|
50
50
|
|
|
51
|
-
> New in v2.
|
|
51
|
+
> New in v2.3.0: Added document and folder sidebar icon tools, plus richer hierarchy detection for inline LinkedPage references and synced-doc embeds.
|
|
52
52
|
|
|
53
53
|
## Choose Your Path
|
|
54
54
|
| Goal | Start here |
|
|
@@ -170,7 +170,7 @@ Domains:
|
|
|
170
170
|
|
|
171
171
|
- Workspace: create, inspect, update, delete, and traverse workspaces
|
|
172
172
|
- Organization: collections, collection-rule sync, workspace blueprints, and experimental organize or folder helpers
|
|
173
|
-
- Documents: search, read, create, publish, move, tag, import/export, semantic composition, template inspection and native instantiation, capability and fidelity reporting, and block-level mutation
|
|
173
|
+
- Documents: search, read, create, publish, move, tag, custom properties, import/export, semantic composition, template inspection and native instantiation, capability and fidelity reporting, and block-level mutation
|
|
174
174
|
- Databases: create columns, add rows, update rows, inspect schema, and compose database structures from intent
|
|
175
175
|
- Comments: list, create, update, delete, and resolve
|
|
176
176
|
- History: version history listing
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,8 @@ import { registerNotificationTools } from "./tools/notifications.js";
|
|
|
14
14
|
import { loginWithPassword } from "./auth.js";
|
|
15
15
|
import { registerAuthTools } from "./tools/auth.js";
|
|
16
16
|
import { registerOrganizeTools } from "./tools/organize.js";
|
|
17
|
+
import { registerPropertyTools } from "./tools/properties.js";
|
|
18
|
+
import { registerIconTools } from "./tools/icons.js";
|
|
17
19
|
import { runCli } from "./cli.js";
|
|
18
20
|
import { startHttpMcpServer } from "./sse.js";
|
|
19
21
|
import { existsSync } from "fs";
|
|
@@ -165,6 +167,8 @@ async function buildServer() {
|
|
|
165
167
|
registerCommentTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|
|
166
168
|
registerHistoryTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|
|
167
169
|
registerOrganizeTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|
|
170
|
+
registerPropertyTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|
|
171
|
+
registerIconTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|
|
168
172
|
registerUserTools(server, gql);
|
|
169
173
|
registerUserCRUDTools(server, gql);
|
|
170
174
|
if (config.authMode !== "oauth") {
|
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",
|
|
@@ -38,7 +41,9 @@ const ALL_TOOLS = [
|
|
|
38
41
|
"get_capabilities",
|
|
39
42
|
"get_collection",
|
|
40
43
|
"get_doc",
|
|
44
|
+
"get_doc_icon",
|
|
41
45
|
"get_edgeless_canvas",
|
|
46
|
+
"get_folder_icon",
|
|
42
47
|
"get_orphan_docs",
|
|
43
48
|
"get_workspace",
|
|
44
49
|
"inspect_template_structure",
|
|
@@ -47,6 +52,7 @@ const ALL_TOOLS = [
|
|
|
47
52
|
"list_children",
|
|
48
53
|
"list_collections",
|
|
49
54
|
"list_comments",
|
|
55
|
+
"list_doc_properties",
|
|
50
56
|
"list_docs",
|
|
51
57
|
"list_docs_by_tag",
|
|
52
58
|
"list_histories",
|
|
@@ -71,13 +77,16 @@ const ALL_TOOLS = [
|
|
|
71
77
|
"revoke_access_token",
|
|
72
78
|
"revoke_doc",
|
|
73
79
|
"search_docs",
|
|
80
|
+
"set_doc_property",
|
|
74
81
|
"sign_in",
|
|
75
82
|
"update_collection",
|
|
76
83
|
"update_collection_rules",
|
|
77
84
|
"update_comment",
|
|
78
85
|
"update_database_row",
|
|
86
|
+
"update_doc_icon",
|
|
79
87
|
"update_doc_title",
|
|
80
88
|
"update_edgeless_block",
|
|
89
|
+
"update_folder_icon",
|
|
81
90
|
"update_frame_children",
|
|
82
91
|
"update_profile",
|
|
83
92
|
"update_settings",
|
|
@@ -97,9 +106,11 @@ const TOOL_GROUPS = {
|
|
|
97
106
|
append_markdown: ["docs", "docs.markdown", "docs.write", "write"],
|
|
98
107
|
append_semantic_section: ["docs", "docs.semantic", "docs.write", "write"],
|
|
99
108
|
cleanup_blobs: ["blobs", "blobs.write", "cleanup", "destructive", "write"],
|
|
109
|
+
clear_doc_property: ["docs", "docs.properties", "docs.write", "write"],
|
|
100
110
|
compose_database_from_intent: ["docs", "docs.database", "docs.intent", "docs.write", "write"],
|
|
101
111
|
create_collection: ["organize", "organize.collections", "organize.write", "write"],
|
|
102
112
|
create_comment: ["comments", "comments.write", "write"],
|
|
113
|
+
create_custom_property: ["docs", "docs.properties", "docs.write", "write"],
|
|
103
114
|
create_doc: ["docs", "docs.write", "write"],
|
|
104
115
|
create_doc_from_markdown: ["docs", "docs.markdown", "docs.write", "write"],
|
|
105
116
|
create_folder: ["organize", "organize.folders", "organize.write", "experimental", "write"],
|
|
@@ -112,6 +123,7 @@ const TOOL_GROUPS = {
|
|
|
112
123
|
delete_block: ["docs", "docs.edgeless", "docs.write", "destructive", "write"],
|
|
113
124
|
delete_collection: ["organize", "organize.collections", "organize.write", "destructive", "write"],
|
|
114
125
|
delete_comment: ["comments", "comments.write", "destructive", "write"],
|
|
126
|
+
delete_custom_property: ["docs", "docs.properties", "docs.write", "destructive", "write"],
|
|
115
127
|
delete_database_row: ["docs", "docs.database", "docs.write", "destructive", "write"],
|
|
116
128
|
delete_doc: ["docs", "docs.write", "destructive", "write"],
|
|
117
129
|
delete_folder: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
|
|
@@ -125,7 +137,9 @@ const TOOL_GROUPS = {
|
|
|
125
137
|
get_capabilities: ["docs", "docs.read", "read"],
|
|
126
138
|
get_collection: ["organize", "organize.collections", "organize.read", "read"],
|
|
127
139
|
get_doc: ["docs", "docs.read", "read"],
|
|
140
|
+
get_doc_icon: ["docs", "docs.read", "read"],
|
|
128
141
|
get_edgeless_canvas: ["docs", "docs.edgeless", "docs.surface", "docs.read", "read"],
|
|
142
|
+
get_folder_icon: ["organize", "organize.folders", "organize.read", "experimental", "read"],
|
|
129
143
|
get_orphan_docs: ["docs", "docs.tree", "docs.read", "read"],
|
|
130
144
|
get_workspace: ["workspaces", "workspaces.read", "read"],
|
|
131
145
|
inspect_template_structure: ["docs", "docs.template", "docs.read", "read"],
|
|
@@ -134,6 +148,7 @@ const TOOL_GROUPS = {
|
|
|
134
148
|
list_children: ["docs", "docs.tree", "docs.read", "read"],
|
|
135
149
|
list_collections: ["organize", "organize.collections", "organize.read", "read"],
|
|
136
150
|
list_comments: ["comments", "comments.read", "read"],
|
|
151
|
+
list_doc_properties: ["docs", "docs.properties", "docs.read", "read"],
|
|
137
152
|
list_docs: ["docs", "docs.read", "read"],
|
|
138
153
|
list_docs_by_tag: ["docs", "docs.tags", "docs.read", "read"],
|
|
139
154
|
list_histories: ["history", "history.read", "read"],
|
|
@@ -158,13 +173,16 @@ const TOOL_GROUPS = {
|
|
|
158
173
|
revoke_access_token: ["access_tokens", "access_tokens.write", "admin", "destructive", "write"],
|
|
159
174
|
revoke_doc: ["docs", "docs.share", "docs.write", "destructive", "write"],
|
|
160
175
|
search_docs: ["docs", "docs.read", "read"],
|
|
176
|
+
set_doc_property: ["docs", "docs.properties", "docs.write", "write"],
|
|
161
177
|
sign_in: ["users", "users.auth", "auth", "write"],
|
|
162
178
|
update_collection: ["organize", "organize.collections", "organize.write", "write"],
|
|
163
179
|
update_collection_rules: ["organize", "organize.collections", "organize.write", "write"],
|
|
164
180
|
update_comment: ["comments", "comments.write", "write"],
|
|
165
181
|
update_database_row: ["docs", "docs.database", "docs.write", "write"],
|
|
182
|
+
update_doc_icon: ["docs", "docs.write", "write"],
|
|
166
183
|
update_doc_title: ["docs", "docs.write", "write"],
|
|
167
184
|
update_edgeless_block: ["docs", "docs.edgeless", "docs.write", "write"],
|
|
185
|
+
update_folder_icon: ["organize", "organize.folders", "organize.write", "experimental", "write"],
|
|
168
186
|
update_frame_children: ["docs", "docs.edgeless", "docs.write", "write"],
|
|
169
187
|
update_profile: ["users", "users.write", "admin", "write"],
|
|
170
188
|
update_settings: ["users", "users.write", "admin", "write"],
|
|
@@ -181,7 +199,9 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
181
199
|
"get_capabilities",
|
|
182
200
|
"get_collection",
|
|
183
201
|
"get_doc",
|
|
202
|
+
"get_doc_icon",
|
|
184
203
|
"get_edgeless_canvas",
|
|
204
|
+
"get_folder_icon",
|
|
185
205
|
"get_orphan_docs",
|
|
186
206
|
"get_workspace",
|
|
187
207
|
"inspect_template_structure",
|
|
@@ -189,6 +209,7 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
189
209
|
"list_children",
|
|
190
210
|
"list_collections",
|
|
191
211
|
"list_comments",
|
|
212
|
+
"list_doc_properties",
|
|
192
213
|
"list_docs",
|
|
193
214
|
"list_docs_by_tag",
|
|
194
215
|
"list_histories",
|
|
@@ -217,6 +238,7 @@ const CORE_TOOLS = new Set([
|
|
|
217
238
|
"find_doc_by_title",
|
|
218
239
|
"get_capabilities",
|
|
219
240
|
"get_doc",
|
|
241
|
+
"get_doc_icon",
|
|
220
242
|
"get_workspace",
|
|
221
243
|
"list_children",
|
|
222
244
|
"list_docs",
|
|
@@ -231,6 +253,7 @@ const CORE_TOOLS = new Set([
|
|
|
231
253
|
"search_docs",
|
|
232
254
|
"sign_in",
|
|
233
255
|
"update_database_row",
|
|
256
|
+
"update_doc_icon",
|
|
234
257
|
"update_doc_title",
|
|
235
258
|
]);
|
|
236
259
|
const AUTHORING_EXCLUDED_GROUPS = new Set([
|
package/dist/tools/docs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { generateKeyBetween } from "fractional-indexing";
|
|
2
|
+
import { generateKeyBetween, generateNKeysBetween } from "fractional-indexing";
|
|
3
3
|
import { receipt, text } from "../util/mcp.js";
|
|
4
4
|
import { wsUrlFromGraphQLEndpoint, connectWorkspaceSocket, joinWorkspace, loadDoc, pushDocUpdate, deleteDoc as wsDeleteDoc } from "../ws.js";
|
|
5
5
|
import * as Y from "yjs";
|
|
@@ -7,6 +7,53 @@ import { parseMarkdownToOperations } from "../markdown/parse.js";
|
|
|
7
7
|
import { renderBlocksToMarkdown } from "../markdown/render.js";
|
|
8
8
|
import { addOrganizeLinkToFolder } from "./organize.js";
|
|
9
9
|
import { DEFAULT_NOTE_XYWH, DEFAULT_STACK_GAP_HORIZONTAL, DEFAULT_STACK_GAP_VERTICAL, SIDE_TO_NORMALIZED_POSITION, encloseBounds, estimateConnectorLabelXYWH, estimateNoteHeightForMarkdown, formatXywhString, parseXywhString, pickConnectorSides, pickFurthestInDirection, sortByFractionalIndex, stackRelativeTo, } from "../edgeless/layout.js";
|
|
10
|
+
function collectLinkedChildIds(blocks) {
|
|
11
|
+
const databaseRowIds = new Set();
|
|
12
|
+
for (const [, raw] of blocks) {
|
|
13
|
+
if (!(raw instanceof Y.Map))
|
|
14
|
+
continue;
|
|
15
|
+
if (raw.get("sys:flavour") !== "affine:database")
|
|
16
|
+
continue;
|
|
17
|
+
const rows = raw.get("sys:children");
|
|
18
|
+
if (rows instanceof Y.Array) {
|
|
19
|
+
rows.forEach((entry) => {
|
|
20
|
+
if (typeof entry === "string")
|
|
21
|
+
databaseRowIds.add(entry);
|
|
22
|
+
else if (Array.isArray(entry)) {
|
|
23
|
+
for (const child of entry)
|
|
24
|
+
if (typeof child === "string")
|
|
25
|
+
databaseRowIds.add(child);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const ids = [];
|
|
31
|
+
for (const [blockId, raw] of blocks) {
|
|
32
|
+
if (!(raw instanceof Y.Map))
|
|
33
|
+
continue;
|
|
34
|
+
const flavour = raw.get("sys:flavour");
|
|
35
|
+
if (flavour === "affine:embed-linked-doc" || flavour === "affine:embed-synced-doc") {
|
|
36
|
+
const pid = raw.get("prop:pageId");
|
|
37
|
+
if (typeof pid === "string" && pid)
|
|
38
|
+
ids.push(pid);
|
|
39
|
+
}
|
|
40
|
+
if (databaseRowIds.has(String(blockId)))
|
|
41
|
+
continue;
|
|
42
|
+
const propText = raw.get("prop:text");
|
|
43
|
+
if (propText instanceof Y.Text) {
|
|
44
|
+
const delta = propText.toDelta();
|
|
45
|
+
if (Array.isArray(delta)) {
|
|
46
|
+
for (const d of delta) {
|
|
47
|
+
const reference = d.attributes?.reference;
|
|
48
|
+
if (reference?.type === "LinkedPage" && typeof reference.pageId === "string" && reference.pageId) {
|
|
49
|
+
ids.push(reference.pageId);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return ids;
|
|
56
|
+
}
|
|
10
57
|
const WorkspaceId = z.string().min(1, "workspaceId required");
|
|
11
58
|
const DocId = z.string().min(1, "docId required");
|
|
12
59
|
const MarkdownContent = z.string().min(1, "markdown required");
|
|
@@ -166,22 +213,30 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
166
213
|
return makeText(delta);
|
|
167
214
|
}
|
|
168
215
|
/**
|
|
169
|
-
* Extract
|
|
170
|
-
*
|
|
216
|
+
* Extract inline LinkedPage reference IDs from a Y.Text value. AFFiNE stores
|
|
217
|
+
* @-mentions as zero-width text deltas whose page id lives in attributes.
|
|
171
218
|
*/
|
|
172
|
-
function
|
|
173
|
-
const propText = rowBlock.get("prop:text");
|
|
219
|
+
function extractLinkedPageRefs(propText) {
|
|
174
220
|
if (!(propText instanceof Y.Text))
|
|
175
|
-
return
|
|
221
|
+
return [];
|
|
176
222
|
const delta = propText.toDelta();
|
|
177
223
|
if (!Array.isArray(delta))
|
|
178
|
-
return
|
|
224
|
+
return [];
|
|
225
|
+
const refs = [];
|
|
179
226
|
for (const d of delta) {
|
|
180
|
-
|
|
181
|
-
|
|
227
|
+
const reference = d.attributes?.reference;
|
|
228
|
+
if (reference?.type === "LinkedPage" && typeof reference.pageId === "string" && reference.pageId.length > 0) {
|
|
229
|
+
refs.push(reference.pageId);
|
|
182
230
|
}
|
|
183
231
|
}
|
|
184
|
-
return
|
|
232
|
+
return refs;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Extract a linked-doc page ID from a database row block's prop:text,
|
|
236
|
+
* if it contains a LinkedPage reference delta. Returns null otherwise.
|
|
237
|
+
*/
|
|
238
|
+
function readLinkedDocId(rowBlock) {
|
|
239
|
+
return extractLinkedPageRefs(rowBlock.get("prop:text"))[0] ?? null;
|
|
185
240
|
}
|
|
186
241
|
function asText(value) {
|
|
187
242
|
if (value instanceof Y.Text)
|
|
@@ -1340,16 +1395,23 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
1340
1395
|
const rowIds = [];
|
|
1341
1396
|
const columnIds = [];
|
|
1342
1397
|
const tableData = normalized.tableData ?? [];
|
|
1398
|
+
// Row/column `order` must be valid fractional-indexing keys. AFFiNE's
|
|
1399
|
+
// editor computes the next order with generateKeyBetween(prevOrder, ...)
|
|
1400
|
+
// when inserting a row/column; plain strings like "r0000" render but make
|
|
1401
|
+
// that call throw "invalid order key", so rows/columns cannot be added in
|
|
1402
|
+
// the UI. Use real keys (a0, a1, ...) so insertion works.
|
|
1403
|
+
const rowOrders = generateNKeysBetween(null, null, normalized.rows);
|
|
1404
|
+
const columnOrders = generateNKeysBetween(null, null, normalized.columns);
|
|
1343
1405
|
for (let i = 0; i < normalized.rows; i++) {
|
|
1344
1406
|
const rowId = generateId();
|
|
1345
1407
|
block.set(`prop:rows.${rowId}.rowId`, rowId);
|
|
1346
|
-
block.set(`prop:rows.${rowId}.order`,
|
|
1408
|
+
block.set(`prop:rows.${rowId}.order`, rowOrders[i]);
|
|
1347
1409
|
rowIds.push(rowId);
|
|
1348
1410
|
}
|
|
1349
1411
|
for (let i = 0; i < normalized.columns; i++) {
|
|
1350
1412
|
const columnId = generateId();
|
|
1351
1413
|
block.set(`prop:columns.${columnId}.columnId`, columnId);
|
|
1352
|
-
block.set(`prop:columns.${columnId}.order`,
|
|
1414
|
+
block.set(`prop:columns.${columnId}.order`, columnOrders[i]);
|
|
1353
1415
|
columnIds.push(columnId);
|
|
1354
1416
|
}
|
|
1355
1417
|
for (let rowIndex = 0; rowIndex < rowIds.length; rowIndex += 1) {
|
|
@@ -2074,6 +2136,13 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2074
2136
|
return [];
|
|
2075
2137
|
}
|
|
2076
2138
|
function extractTableData(block) {
|
|
2139
|
+
const compareOrder = (left, right) => {
|
|
2140
|
+
if (left < right)
|
|
2141
|
+
return -1;
|
|
2142
|
+
if (left > right)
|
|
2143
|
+
return 1;
|
|
2144
|
+
return 0;
|
|
2145
|
+
};
|
|
2077
2146
|
const rowsValue = block.get("prop:rows");
|
|
2078
2147
|
const columnsValue = block.get("prop:columns");
|
|
2079
2148
|
const cellsValue = block.get("prop:cells");
|
|
@@ -2084,7 +2153,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2084
2153
|
? payload.order
|
|
2085
2154
|
: rowId,
|
|
2086
2155
|
}))
|
|
2087
|
-
.sort((a, b) => a.order
|
|
2156
|
+
.sort((a, b) => compareOrder(a.order, b.order));
|
|
2088
2157
|
let columnEntries = mapEntries(columnsValue)
|
|
2089
2158
|
.map(([columnId, payload]) => ({
|
|
2090
2159
|
columnId,
|
|
@@ -2092,7 +2161,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2092
2161
|
? payload.order
|
|
2093
2162
|
: columnId,
|
|
2094
2163
|
}))
|
|
2095
|
-
.sort((a, b) => a.order
|
|
2164
|
+
.sort((a, b) => compareOrder(a.order, b.order));
|
|
2096
2165
|
let cells = new Map();
|
|
2097
2166
|
if (rowEntries.length === 0 || columnEntries.length === 0) {
|
|
2098
2167
|
// Fallback: AFFiNE self-hosted stores table props as flat dot-notation keys
|
|
@@ -2122,10 +2191,10 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2122
2191
|
if (flatRows.size > 0 && flatColumns.size > 0) {
|
|
2123
2192
|
rowEntries = Array.from(flatRows.entries())
|
|
2124
2193
|
.map(([rowId, order]) => ({ rowId, order }))
|
|
2125
|
-
.sort((a, b) => a.order
|
|
2194
|
+
.sort((a, b) => compareOrder(a.order, b.order));
|
|
2126
2195
|
columnEntries = Array.from(flatColumns.entries())
|
|
2127
2196
|
.map(([columnId, order]) => ({ columnId, order }))
|
|
2128
|
-
.sort((a, b) => a.order
|
|
2197
|
+
.sort((a, b) => compareOrder(a.order, b.order));
|
|
2129
2198
|
cells = flatCells;
|
|
2130
2199
|
}
|
|
2131
2200
|
}
|
|
@@ -3788,7 +3857,9 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3788
3857
|
const flavour = raw.get("sys:flavour");
|
|
3789
3858
|
const parentId = raw.get("sys:parent");
|
|
3790
3859
|
const type = raw.get("prop:type");
|
|
3791
|
-
const
|
|
3860
|
+
const propText = raw.get("prop:text");
|
|
3861
|
+
const textValue = asText(propText);
|
|
3862
|
+
const linkedDocIds = extractLinkedPageRefs(propText);
|
|
3792
3863
|
const language = raw.get("prop:language");
|
|
3793
3864
|
const checked = raw.get("prop:checked");
|
|
3794
3865
|
const childIds = childIdsFrom(raw.get("sys:children"));
|
|
@@ -3804,6 +3875,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3804
3875
|
flavour: typeof flavour === "string" ? flavour : null,
|
|
3805
3876
|
type: typeof type === "string" ? type : null,
|
|
3806
3877
|
text: textValue.length > 0 ? textValue : null,
|
|
3878
|
+
linkedDocIds,
|
|
3807
3879
|
checked: typeof checked === "boolean" ? checked : null,
|
|
3808
3880
|
language: typeof language === "string" ? language : null,
|
|
3809
3881
|
childIds,
|
|
@@ -4758,13 +4830,8 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4758
4830
|
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4759
4831
|
const blocks = doc.getMap("blocks");
|
|
4760
4832
|
const kids = [];
|
|
4761
|
-
for (const
|
|
4762
|
-
if (
|
|
4763
|
-
continue;
|
|
4764
|
-
if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
|
|
4765
|
-
continue;
|
|
4766
|
-
const pid = raw.get("prop:pageId");
|
|
4767
|
-
if (typeof pid === "string" && pid && titleById.has(pid)) {
|
|
4833
|
+
for (const pid of collectLinkedChildIds(blocks)) {
|
|
4834
|
+
if (titleById.has(pid) && !kids.includes(pid)) {
|
|
4768
4835
|
kids.push(pid);
|
|
4769
4836
|
allChildren.add(pid);
|
|
4770
4837
|
}
|
|
@@ -4818,14 +4885,8 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4818
4885
|
const doc = new Y.Doc();
|
|
4819
4886
|
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4820
4887
|
const blocks = doc.getMap("blocks");
|
|
4821
|
-
for (const
|
|
4822
|
-
|
|
4823
|
-
continue;
|
|
4824
|
-
if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
|
|
4825
|
-
continue;
|
|
4826
|
-
const pageId = raw.get("prop:pageId");
|
|
4827
|
-
if (typeof pageId === "string" && pageId)
|
|
4828
|
-
allChildren.add(pageId);
|
|
4888
|
+
for (const pageId of collectLinkedChildIds(blocks)) {
|
|
4889
|
+
allChildren.add(pageId);
|
|
4829
4890
|
}
|
|
4830
4891
|
}
|
|
4831
4892
|
const baseUrl = (process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, "")).replace(/\/$/, "");
|
|
@@ -4844,7 +4905,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4844
4905
|
};
|
|
4845
4906
|
server.registerTool("get_orphan_docs", {
|
|
4846
4907
|
title: "Get Orphan Documents",
|
|
4847
|
-
description: "Find all documents that have no parent (not linked from any other doc via embed_linked_doc). Useful for workspace hygiene. Note: scans all docs — O(n).",
|
|
4908
|
+
description: "Find all documents that have no parent (not linked from any other doc via embed_linked_doc / embed_synced_doc blocks or inline LinkedPage references). Useful for workspace hygiene. Note: scans all docs — O(n).",
|
|
4848
4909
|
inputSchema: { workspaceId: z.string().optional() },
|
|
4849
4910
|
}, getOrphanDocsHandler);
|
|
4850
4911
|
const listChildrenHandler = async (parsed) => {
|
|
@@ -4857,11 +4918,15 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4857
4918
|
try {
|
|
4858
4919
|
await joinWorkspace(socket, workspaceId);
|
|
4859
4920
|
const titleById = new Map();
|
|
4921
|
+
const workspacePageIds = new Set();
|
|
4922
|
+
let hasWorkspaceMetadata = false;
|
|
4860
4923
|
const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
|
|
4861
4924
|
if (wsSnap.missing) {
|
|
4925
|
+
hasWorkspaceMetadata = true;
|
|
4862
4926
|
const wsDoc = new Y.Doc();
|
|
4863
4927
|
Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
|
|
4864
4928
|
for (const page of getWorkspacePageEntries(wsDoc.getMap("meta"))) {
|
|
4929
|
+
workspacePageIds.add(page.id);
|
|
4865
4930
|
if (page.title)
|
|
4866
4931
|
titleById.set(page.id, page.title);
|
|
4867
4932
|
}
|
|
@@ -4873,16 +4938,15 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4873
4938
|
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4874
4939
|
const blocks = doc.getMap("blocks");
|
|
4875
4940
|
const children = [];
|
|
4876
|
-
|
|
4877
|
-
|
|
4941
|
+
const seen = new Set();
|
|
4942
|
+
for (const pageId of collectLinkedChildIds(blocks)) {
|
|
4943
|
+
if (hasWorkspaceMetadata && !workspacePageIds.has(pageId))
|
|
4878
4944
|
continue;
|
|
4879
|
-
if (
|
|
4945
|
+
if (seen.has(pageId))
|
|
4880
4946
|
continue;
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}` });
|
|
4885
|
-
}
|
|
4947
|
+
seen.add(pageId);
|
|
4948
|
+
children.push({ docId: pageId, title: titleById.get(pageId) ?? null,
|
|
4949
|
+
url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}` });
|
|
4886
4950
|
}
|
|
4887
4951
|
return text({ docId: parsed.docId, count: children.length, children });
|
|
4888
4952
|
}
|
|
@@ -4892,7 +4956,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4892
4956
|
};
|
|
4893
4957
|
server.registerTool("list_children", {
|
|
4894
4958
|
title: "List Document Children",
|
|
4895
|
-
description: "List the direct children of a document in the sidebar (embed_linked_doc blocks). Returns docId, title, and URL for each child.",
|
|
4959
|
+
description: "List the direct children of a document in the sidebar (embed_linked_doc / embed_synced_doc blocks and inline LinkedPage references). Returns docId, title, and URL for each child.",
|
|
4896
4960
|
inputSchema: {
|
|
4897
4961
|
workspaceId: z.string().optional(),
|
|
4898
4962
|
docId: z.string().describe("The parent doc whose children to list."),
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { receipt } from "../util/mcp.js";
|
|
3
|
+
import { connectWorkspaceSocket, joinWorkspace, wsUrlFromGraphQLEndpoint, } from "../ws.js";
|
|
4
|
+
import { docIconKey, folderIconKey, getExplorerIcon, normalizeIconInput, setExplorerIcon, } from "../util/explorerIcon.js";
|
|
5
|
+
/**
|
|
6
|
+
* Zod shape for the `icon` parameter shared by both setters. Accepts an emoji
|
|
7
|
+
* shorthand string, a full `{type:"emoji",unicode}` / `{type:"icon",name}`
|
|
8
|
+
* object, or `null` to clear the icon.
|
|
9
|
+
*/
|
|
10
|
+
const iconSchema = z
|
|
11
|
+
.union([
|
|
12
|
+
z.string(),
|
|
13
|
+
z.object({ type: z.literal("emoji"), unicode: z.string() }),
|
|
14
|
+
z.object({ type: z.literal("icon"), name: z.string() }),
|
|
15
|
+
z.null(),
|
|
16
|
+
])
|
|
17
|
+
.describe('Emoji shorthand ("🧪"), a full object ({type:"emoji",unicode:"🧪"} or ' +
|
|
18
|
+
'{type:"icon",name:"check"}), or null to remove the icon.');
|
|
19
|
+
/**
|
|
20
|
+
* Registers the explorer-icon tools: set/clear and read the Notion-style
|
|
21
|
+
* sidebar icon on a document or an organize folder. All four share the
|
|
22
|
+
* `explorerIcon` sub-doc helper so the storage model lives in one place.
|
|
23
|
+
*/
|
|
24
|
+
export function registerIconTools(server, gql, defaults) {
|
|
25
|
+
function resolveWorkspaceId(workspaceId) {
|
|
26
|
+
const resolved = workspaceId || defaults.workspaceId;
|
|
27
|
+
if (!resolved) {
|
|
28
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID.");
|
|
29
|
+
}
|
|
30
|
+
return resolved;
|
|
31
|
+
}
|
|
32
|
+
async function withSocket(workspaceId, fn) {
|
|
33
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(gql.endpoint);
|
|
34
|
+
const socket = await connectWorkspaceSocket(wsUrl, gql.cookie, gql.bearer);
|
|
35
|
+
try {
|
|
36
|
+
await joinWorkspace(socket, workspaceId);
|
|
37
|
+
return await fn(socket);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
socket.disconnect();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// ─── update_doc_icon ────────────────────────────────────────────────────────
|
|
44
|
+
const updateDocIconHandler = async (parsed) => {
|
|
45
|
+
const workspaceId = resolveWorkspaceId(parsed.workspaceId);
|
|
46
|
+
const icon = normalizeIconInput(parsed.icon);
|
|
47
|
+
const result = await withSocket(workspaceId, (socket) => setExplorerIcon(socket, workspaceId, docIconKey(parsed.docId), icon));
|
|
48
|
+
return receipt("doc.update_icon", {
|
|
49
|
+
workspaceId,
|
|
50
|
+
docId: parsed.docId,
|
|
51
|
+
icon: result.icon,
|
|
52
|
+
cleared: result.icon === null,
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
server.registerTool("update_doc_icon", {
|
|
56
|
+
title: "Update Document Icon",
|
|
57
|
+
description: "Set or clear the sidebar icon (the Notion-style emoji slot) on a document. " +
|
|
58
|
+
"Pass an emoji string, a full icon object, or null to remove it.",
|
|
59
|
+
inputSchema: {
|
|
60
|
+
workspaceId: z.string().optional(),
|
|
61
|
+
docId: z.string().describe("The document whose icon to update."),
|
|
62
|
+
icon: iconSchema,
|
|
63
|
+
},
|
|
64
|
+
}, updateDocIconHandler);
|
|
65
|
+
// ─── update_folder_icon ─────────────────────────────────────────────────────
|
|
66
|
+
const updateFolderIconHandler = async (parsed) => {
|
|
67
|
+
const workspaceId = resolveWorkspaceId(parsed.workspaceId);
|
|
68
|
+
const icon = normalizeIconInput(parsed.icon);
|
|
69
|
+
const result = await withSocket(workspaceId, (socket) => setExplorerIcon(socket, workspaceId, folderIconKey(parsed.folderId), icon));
|
|
70
|
+
return receipt("folder.update_icon", {
|
|
71
|
+
workspaceId,
|
|
72
|
+
folderId: parsed.folderId,
|
|
73
|
+
icon: result.icon,
|
|
74
|
+
cleared: result.icon === null,
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
server.registerTool("update_folder_icon", {
|
|
78
|
+
title: "Update Folder Icon",
|
|
79
|
+
description: "Set or clear the sidebar icon on an organize folder. " +
|
|
80
|
+
"Pass an emoji string, a full icon object, or null to remove it. Experimental.",
|
|
81
|
+
inputSchema: {
|
|
82
|
+
workspaceId: z.string().optional(),
|
|
83
|
+
folderId: z.string().describe("The organize folder whose icon to update."),
|
|
84
|
+
icon: iconSchema,
|
|
85
|
+
},
|
|
86
|
+
}, updateFolderIconHandler);
|
|
87
|
+
// ─── get_doc_icon ───────────────────────────────────────────────────────────
|
|
88
|
+
const getDocIconHandler = async (parsed) => {
|
|
89
|
+
const workspaceId = resolveWorkspaceId(parsed.workspaceId);
|
|
90
|
+
const result = await withSocket(workspaceId, (socket) => getExplorerIcon(socket, workspaceId, docIconKey(parsed.docId)));
|
|
91
|
+
return receipt("doc.get_icon", {
|
|
92
|
+
workspaceId,
|
|
93
|
+
docId: parsed.docId,
|
|
94
|
+
icon: result.icon,
|
|
95
|
+
hasIcon: result.icon !== null,
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
server.registerTool("get_doc_icon", {
|
|
99
|
+
title: "Get Document Icon",
|
|
100
|
+
description: "Read the current sidebar icon of a document. Returns null when none is set.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
workspaceId: z.string().optional(),
|
|
103
|
+
docId: z.string().describe("The document whose icon to read."),
|
|
104
|
+
},
|
|
105
|
+
}, getDocIconHandler);
|
|
106
|
+
// ─── get_folder_icon ────────────────────────────────────────────────────────
|
|
107
|
+
const getFolderIconHandler = async (parsed) => {
|
|
108
|
+
const workspaceId = resolveWorkspaceId(parsed.workspaceId);
|
|
109
|
+
const result = await withSocket(workspaceId, (socket) => getExplorerIcon(socket, workspaceId, folderIconKey(parsed.folderId)));
|
|
110
|
+
return receipt("folder.get_icon", {
|
|
111
|
+
workspaceId,
|
|
112
|
+
folderId: parsed.folderId,
|
|
113
|
+
icon: result.icon,
|
|
114
|
+
hasIcon: result.icon !== null,
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
server.registerTool("get_folder_icon", {
|
|
118
|
+
title: "Get Folder Icon",
|
|
119
|
+
description: "Read the current sidebar icon of an organize folder. Returns null when none is set. Experimental.",
|
|
120
|
+
inputSchema: {
|
|
121
|
+
workspaceId: z.string().optional(),
|
|
122
|
+
folderId: z.string().describe("The organize folder whose icon to read."),
|
|
123
|
+
},
|
|
124
|
+
}, getFolderIconHandler);
|
|
125
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
3
|
+
import * as Y from "yjs";
|
|
4
|
+
import { text } from "../util/mcp.js";
|
|
5
|
+
import { wsUrlFromGraphQLEndpoint, connectWorkspaceSocket, joinWorkspace, loadDoc, pushDocUpdate, } from "../ws.js";
|
|
6
|
+
/**
|
|
7
|
+
* Doc custom properties live in dedicated Yjs sub-docs synced by guid, NOT in
|
|
8
|
+
* the page doc or the workspace root meta. AFFiNE's WorkspaceDB (an ORM on top
|
|
9
|
+
* of Yjs) maps one table to one sub-doc whose guid is `db$<tableName>`:
|
|
10
|
+
*
|
|
11
|
+
* - `db$docCustomPropertyInfo`: the workspace-wide property *definitions*
|
|
12
|
+
* (schema). Top-level YMap keyed by propertyId -> { id, name, type, index,
|
|
13
|
+
* icon, show, isDeleted }.
|
|
14
|
+
* - `db$docProperties`: the per-doc property *values*. Top-level YMap keyed by
|
|
15
|
+
* docId -> { id, ...builtins, "custom:<propertyId>": <value> }.
|
|
16
|
+
*
|
|
17
|
+
* A custom property must have a definition in `db$docCustomPropertyInfo` to be
|
|
18
|
+
* rendered/editable in the AFFiNE UI. Writing a value without a matching
|
|
19
|
+
* definition stores orphan data that the UI ignores.
|
|
20
|
+
*
|
|
21
|
+
* Values are stored as strings, encoded per type:
|
|
22
|
+
* - text: raw string
|
|
23
|
+
* - number: stringified number
|
|
24
|
+
* - checkbox: "true" | "false"
|
|
25
|
+
* - date: "YYYY-MM-DD"
|
|
26
|
+
*
|
|
27
|
+
* References (AFFiNE repo):
|
|
28
|
+
* - modules/db/services/db.ts -> guid `db$${tableName}`
|
|
29
|
+
* - orm/core/adapters/yjs/table.ts -> record = top-level YMap keyed by primary key
|
|
30
|
+
* - modules/doc/entities/record.ts -> value key is `custom:<propertyId>`
|
|
31
|
+
*/
|
|
32
|
+
const DOC_PROPERTIES_GUID = "db$docProperties";
|
|
33
|
+
const CUSTOM_PROPERTY_INFO_GUID = "db$docCustomPropertyInfo";
|
|
34
|
+
const DELETED_FLAG = "$$DELETED";
|
|
35
|
+
const CUSTOM_PREFIX = "custom:";
|
|
36
|
+
const SUPPORTED_TYPES = ["text", "number", "checkbox", "date"];
|
|
37
|
+
const WorkspaceId = z.string().min(1, "workspaceId required");
|
|
38
|
+
const DocId = z.string().min(1, "docId required");
|
|
39
|
+
const NANOID_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
|
|
40
|
+
/** Generate a 21-char nanoid-style id, matching AFFiNE's property id format. */
|
|
41
|
+
function generatePropertyId() {
|
|
42
|
+
let id = "";
|
|
43
|
+
for (let i = 0; i < 21; i++) {
|
|
44
|
+
id += NANOID_ALPHABET.charAt(Math.floor(Math.random() * NANOID_ALPHABET.length));
|
|
45
|
+
}
|
|
46
|
+
return id;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Read all live custom-property definitions from the `db$docCustomPropertyInfo`
|
|
50
|
+
* sub-doc, skipping soft-deleted and empty records.
|
|
51
|
+
*/
|
|
52
|
+
function readPropertyDefinitions(doc) {
|
|
53
|
+
const defs = [];
|
|
54
|
+
// Records are top-level (root) Yjs types keyed by id. After applyUpdate, root
|
|
55
|
+
// types are generic AbstractType until doc.getMap(key) casts them, so an
|
|
56
|
+
// `instanceof Y.Map` check would skip every record. Mirror AFFiNE's ORM
|
|
57
|
+
// adapter and materialize each record via getMap.
|
|
58
|
+
for (const key of doc.share.keys()) {
|
|
59
|
+
const data = doc.getMap(key).toJSON();
|
|
60
|
+
if (data[DELETED_FLAG] === true || data.isDeleted === true)
|
|
61
|
+
continue;
|
|
62
|
+
if (Object.keys(data).length === 0)
|
|
63
|
+
continue;
|
|
64
|
+
const type = typeof data.type === "string" ? data.type : "unknown";
|
|
65
|
+
defs.push({
|
|
66
|
+
id: typeof data.id === "string" ? data.id : key,
|
|
67
|
+
name: typeof data.name === "string" ? data.name : null,
|
|
68
|
+
type,
|
|
69
|
+
index: typeof data.index === "string" ? data.index : null,
|
|
70
|
+
icon: typeof data.icon === "string" ? data.icon : null,
|
|
71
|
+
show: typeof data.show === "string" ? data.show : null,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
defs.sort((a, b) => (a.index || "").localeCompare(b.index || ""));
|
|
75
|
+
return defs;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve a definition by exact id, then by unique case-insensitive name.
|
|
79
|
+
* Throws if a name matches more than one definition.
|
|
80
|
+
*/
|
|
81
|
+
function resolveDefinition(defs, property) {
|
|
82
|
+
const byId = defs.find((d) => d.id === property);
|
|
83
|
+
if (byId)
|
|
84
|
+
return byId;
|
|
85
|
+
const lowered = property.trim().toLowerCase();
|
|
86
|
+
const byName = defs.filter((d) => (d.name || "").trim().toLowerCase() === lowered);
|
|
87
|
+
if (byName.length === 1)
|
|
88
|
+
return byName[0];
|
|
89
|
+
if (byName.length > 1) {
|
|
90
|
+
throw new Error(`Property name "${property}" is ambiguous (${byName.length} matches). Use the property id instead.`);
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/** Compute the next fractional index, appending after the current last definition. */
|
|
95
|
+
function nextIndex(defs) {
|
|
96
|
+
const indexes = defs
|
|
97
|
+
.map((d) => d.index)
|
|
98
|
+
.filter((i) => typeof i === "string" && i.length > 0)
|
|
99
|
+
.sort();
|
|
100
|
+
const last = indexes.length ? indexes[indexes.length - 1] : null;
|
|
101
|
+
return generateKeyBetween(last, null);
|
|
102
|
+
}
|
|
103
|
+
/** Encode a JS value into AFFiNE's per-type string representation; throws on invalid input. */
|
|
104
|
+
function encodeValue(type, value) {
|
|
105
|
+
switch (type) {
|
|
106
|
+
case "checkbox": {
|
|
107
|
+
const truthy = value === true ||
|
|
108
|
+
value === 1 ||
|
|
109
|
+
(typeof value === "string" && ["true", "1", "yes"].includes(value.trim().toLowerCase()));
|
|
110
|
+
return truthy ? "true" : "false";
|
|
111
|
+
}
|
|
112
|
+
case "number": {
|
|
113
|
+
const n = typeof value === "number" ? value : Number(String(value).trim());
|
|
114
|
+
if (!Number.isFinite(n)) {
|
|
115
|
+
throw new Error(`number property requires a numeric value, got ${JSON.stringify(value)}`);
|
|
116
|
+
}
|
|
117
|
+
return String(n);
|
|
118
|
+
}
|
|
119
|
+
case "date": {
|
|
120
|
+
const s = String(value).trim();
|
|
121
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
122
|
+
throw new Error(`date property requires "YYYY-MM-DD", got ${JSON.stringify(value)}`);
|
|
123
|
+
}
|
|
124
|
+
const parsed = new Date(`${s}T00:00:00.000Z`);
|
|
125
|
+
if (!Number.isFinite(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== s) {
|
|
126
|
+
throw new Error(`date property requires a valid "YYYY-MM-DD" date, got ${JSON.stringify(value)}`);
|
|
127
|
+
}
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
case "text":
|
|
131
|
+
default:
|
|
132
|
+
return String(value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Decode a stored string back into a typed JS value for output. */
|
|
136
|
+
function decodeValue(type, raw) {
|
|
137
|
+
if (raw === undefined || raw === null)
|
|
138
|
+
return null;
|
|
139
|
+
switch (type) {
|
|
140
|
+
case "checkbox":
|
|
141
|
+
return raw === "true" || raw === true;
|
|
142
|
+
case "number": {
|
|
143
|
+
const n = Number(raw);
|
|
144
|
+
return Number.isFinite(n) ? n : raw;
|
|
145
|
+
}
|
|
146
|
+
default:
|
|
147
|
+
return raw;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Register the five document custom-property tools on the MCP server. */
|
|
151
|
+
export function registerPropertyTools(server, gql, defaults) {
|
|
152
|
+
/** Snapshot the current GraphQL endpoint and auth material for WebSocket use. */
|
|
153
|
+
function getCookieAndEndpoint() {
|
|
154
|
+
return { endpoint: gql.endpoint, cookie: gql.cookie, bearer: gql.bearer };
|
|
155
|
+
}
|
|
156
|
+
/** Resolve the workspace id from the argument or the configured default; throws if absent. */
|
|
157
|
+
function requireWorkspaceId(workspaceId) {
|
|
158
|
+
const id = workspaceId || defaults.workspaceId;
|
|
159
|
+
if (!id) {
|
|
160
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
161
|
+
}
|
|
162
|
+
return id;
|
|
163
|
+
}
|
|
164
|
+
/** Load a WorkspaceDB sub-doc by guid and return it with its pre-mutation state vector. */
|
|
165
|
+
async function loadSubdoc(socket, workspaceId, guid) {
|
|
166
|
+
const snapshot = await loadDoc(socket, workspaceId, guid);
|
|
167
|
+
const doc = new Y.Doc();
|
|
168
|
+
let existed = false;
|
|
169
|
+
if (snapshot.missing) {
|
|
170
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
171
|
+
existed = true;
|
|
172
|
+
}
|
|
173
|
+
return { doc, prevSV: Y.encodeStateVector(doc), existed };
|
|
174
|
+
}
|
|
175
|
+
/** Push only the delta accumulated since `prevSV` back to the sync gateway. */
|
|
176
|
+
async function pushSubdoc(socket, workspaceId, guid, doc, prevSV) {
|
|
177
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
178
|
+
await pushDocUpdate(socket, workspaceId, guid, Buffer.from(delta).toString("base64"));
|
|
179
|
+
}
|
|
180
|
+
/** Throw if the workspace root or the docId is not found in workspace metadata. */
|
|
181
|
+
async function assertDocExists(socket, workspaceId, docId) {
|
|
182
|
+
const snapshot = await loadDoc(socket, workspaceId, workspaceId);
|
|
183
|
+
if (!snapshot.missing) {
|
|
184
|
+
throw new Error(`Workspace root document not found for workspace ${workspaceId}`);
|
|
185
|
+
}
|
|
186
|
+
const wsDoc = new Y.Doc();
|
|
187
|
+
Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
|
|
188
|
+
const pages = wsDoc.getMap("meta").get("pages");
|
|
189
|
+
const exists = pages instanceof Y.Array &&
|
|
190
|
+
pages.toArray().some((p) => p instanceof Y.Map && p.get("id") === docId);
|
|
191
|
+
if (!exists) {
|
|
192
|
+
throw new Error(`docId ${docId} is not present in workspace ${workspaceId}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// list_doc_properties
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
/** Handle `list_doc_properties`: definitions, decoded per-doc values, and orphan values. */
|
|
199
|
+
const listDocPropertiesHandler = async (parsed) => {
|
|
200
|
+
const workspaceId = requireWorkspaceId(parsed.workspaceId);
|
|
201
|
+
const { endpoint, cookie, bearer } = getCookieAndEndpoint();
|
|
202
|
+
const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
|
|
203
|
+
try {
|
|
204
|
+
await joinWorkspace(socket, workspaceId);
|
|
205
|
+
const { doc: infoDoc } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
|
|
206
|
+
const defs = readPropertyDefinitions(infoDoc);
|
|
207
|
+
const { doc: propsDoc } = await loadSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID);
|
|
208
|
+
const record = propsDoc.share.has(parsed.docId)
|
|
209
|
+
? propsDoc.getMap(parsed.docId).toJSON()
|
|
210
|
+
: {};
|
|
211
|
+
const byId = new Map(defs.map((d) => [d.id, d]));
|
|
212
|
+
const properties = defs.map((def) => {
|
|
213
|
+
const raw = record[CUSTOM_PREFIX + def.id];
|
|
214
|
+
return {
|
|
215
|
+
propertyId: def.id,
|
|
216
|
+
name: def.name,
|
|
217
|
+
type: def.type,
|
|
218
|
+
value: decodeValue(def.type, raw),
|
|
219
|
+
set: raw !== undefined && raw !== null,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
// Surface custom values that have no matching (live) definition.
|
|
223
|
+
const orphans = Object.keys(record)
|
|
224
|
+
.filter((k) => k.startsWith(CUSTOM_PREFIX))
|
|
225
|
+
.map((k) => k.slice(CUSTOM_PREFIX.length))
|
|
226
|
+
.filter((id) => !byId.has(id))
|
|
227
|
+
.map((id) => ({ propertyId: id, value: record[CUSTOM_PREFIX + id] }));
|
|
228
|
+
return text({
|
|
229
|
+
workspaceId,
|
|
230
|
+
docId: parsed.docId,
|
|
231
|
+
definitions: defs,
|
|
232
|
+
properties,
|
|
233
|
+
orphanValues: orphans,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
socket.disconnect();
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
server.registerTool("list_doc_properties", {
|
|
241
|
+
title: "List Document Properties",
|
|
242
|
+
description: "List the workspace custom-property definitions and a document's current values for them.",
|
|
243
|
+
inputSchema: {
|
|
244
|
+
workspaceId: WorkspaceId.optional(),
|
|
245
|
+
docId: DocId,
|
|
246
|
+
},
|
|
247
|
+
}, listDocPropertiesHandler);
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// create_custom_property
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
/** Handle `create_custom_property`: append a new workspace-wide definition. */
|
|
252
|
+
const createCustomPropertyHandler = async (parsed) => {
|
|
253
|
+
const workspaceId = requireWorkspaceId(parsed.workspaceId);
|
|
254
|
+
const name = parsed.name.trim();
|
|
255
|
+
if (!name)
|
|
256
|
+
throw new Error("name is required");
|
|
257
|
+
const { endpoint, cookie, bearer } = getCookieAndEndpoint();
|
|
258
|
+
const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
|
|
259
|
+
try {
|
|
260
|
+
await joinWorkspace(socket, workspaceId);
|
|
261
|
+
const { doc, prevSV } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
|
|
262
|
+
const defs = readPropertyDefinitions(doc);
|
|
263
|
+
const id = generatePropertyId();
|
|
264
|
+
const index = nextIndex(defs);
|
|
265
|
+
const record = doc.getMap(id);
|
|
266
|
+
record.set("id", id);
|
|
267
|
+
record.set("name", name);
|
|
268
|
+
record.set("type", parsed.type);
|
|
269
|
+
record.set("index", index);
|
|
270
|
+
if (parsed.icon)
|
|
271
|
+
record.set("icon", parsed.icon);
|
|
272
|
+
await pushSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID, doc, prevSV);
|
|
273
|
+
return text({
|
|
274
|
+
workspaceId,
|
|
275
|
+
propertyId: id,
|
|
276
|
+
name,
|
|
277
|
+
type: parsed.type,
|
|
278
|
+
index,
|
|
279
|
+
created: true,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
socket.disconnect();
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
server.registerTool("create_custom_property", {
|
|
287
|
+
title: "Create Custom Property",
|
|
288
|
+
description: "Create a workspace-wide custom property definition (text, number, checkbox, or date). Returns its propertyId.",
|
|
289
|
+
inputSchema: {
|
|
290
|
+
workspaceId: WorkspaceId.optional(),
|
|
291
|
+
name: z.string().min(1).describe("Display name of the property"),
|
|
292
|
+
type: z.enum(SUPPORTED_TYPES).describe("Property value type"),
|
|
293
|
+
icon: z.string().optional().describe("Optional icon name"),
|
|
294
|
+
},
|
|
295
|
+
}, createCustomPropertyHandler);
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// delete_custom_property
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
/** Handle `delete_custom_property`: soft-delete a definition by id or name. */
|
|
300
|
+
const deleteCustomPropertyHandler = async (parsed) => {
|
|
301
|
+
const workspaceId = requireWorkspaceId(parsed.workspaceId);
|
|
302
|
+
const { endpoint, cookie, bearer } = getCookieAndEndpoint();
|
|
303
|
+
const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
|
|
304
|
+
try {
|
|
305
|
+
await joinWorkspace(socket, workspaceId);
|
|
306
|
+
const { doc, prevSV } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
|
|
307
|
+
const defs = readPropertyDefinitions(doc);
|
|
308
|
+
const def = resolveDefinition(defs, parsed.property);
|
|
309
|
+
if (!def) {
|
|
310
|
+
throw new Error(`No custom property matches "${parsed.property}" in workspace ${workspaceId}`);
|
|
311
|
+
}
|
|
312
|
+
// Mirror AFFiNE: keep the record for legacy override, flag it deleted.
|
|
313
|
+
const record = doc.getMap(def.id);
|
|
314
|
+
record.set("isDeleted", true);
|
|
315
|
+
await pushSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID, doc, prevSV);
|
|
316
|
+
return text({ workspaceId, propertyId: def.id, name: def.name, deleted: true });
|
|
317
|
+
}
|
|
318
|
+
finally {
|
|
319
|
+
socket.disconnect();
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
server.registerTool("delete_custom_property", {
|
|
323
|
+
title: "Delete Custom Property",
|
|
324
|
+
description: "Soft-delete a workspace custom property definition (by propertyId or name). Existing values are hidden.",
|
|
325
|
+
inputSchema: {
|
|
326
|
+
workspaceId: WorkspaceId.optional(),
|
|
327
|
+
property: z.string().min(1).describe("Property id or name"),
|
|
328
|
+
},
|
|
329
|
+
}, deleteCustomPropertyHandler);
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// set_doc_property
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
/** Handle `set_doc_property`: validate, encode, and upsert a doc's property value. */
|
|
334
|
+
const setDocPropertyHandler = async (parsed) => {
|
|
335
|
+
const workspaceId = requireWorkspaceId(parsed.workspaceId);
|
|
336
|
+
const { endpoint, cookie, bearer } = getCookieAndEndpoint();
|
|
337
|
+
const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
|
|
338
|
+
try {
|
|
339
|
+
await joinWorkspace(socket, workspaceId);
|
|
340
|
+
await assertDocExists(socket, workspaceId, parsed.docId);
|
|
341
|
+
const { doc: infoDoc } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
|
|
342
|
+
const defs = readPropertyDefinitions(infoDoc);
|
|
343
|
+
const def = resolveDefinition(defs, parsed.property);
|
|
344
|
+
if (!def) {
|
|
345
|
+
throw new Error(`No custom property matches "${parsed.property}". Create it first with create_custom_property.`);
|
|
346
|
+
}
|
|
347
|
+
if (!SUPPORTED_TYPES.includes(def.type)) {
|
|
348
|
+
throw new Error(`Property "${def.name || def.id}" has type "${def.type}", which set_doc_property cannot edit. Supported: ${SUPPORTED_TYPES.join(", ")}.`);
|
|
349
|
+
}
|
|
350
|
+
const encoded = encodeValue(def.type, parsed.value);
|
|
351
|
+
const { doc, prevSV } = await loadSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID);
|
|
352
|
+
const record = doc.getMap(parsed.docId);
|
|
353
|
+
record.set("id", parsed.docId); // ORM keyField, required by find/observe
|
|
354
|
+
record.set(CUSTOM_PREFIX + def.id, encoded);
|
|
355
|
+
await pushSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID, doc, prevSV);
|
|
356
|
+
return text({
|
|
357
|
+
workspaceId,
|
|
358
|
+
docId: parsed.docId,
|
|
359
|
+
propertyId: def.id,
|
|
360
|
+
name: def.name,
|
|
361
|
+
type: def.type,
|
|
362
|
+
value: decodeValue(def.type, encoded),
|
|
363
|
+
stored: encoded,
|
|
364
|
+
updated: true,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
finally {
|
|
368
|
+
socket.disconnect();
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
server.registerTool("set_doc_property", {
|
|
372
|
+
title: "Set Document Property",
|
|
373
|
+
description: "Set a document's custom property value (property by id or name). Value is validated against the property type (text/number/checkbox/date).",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
workspaceId: WorkspaceId.optional(),
|
|
376
|
+
docId: DocId,
|
|
377
|
+
property: z.string().min(1).describe("Property id or name"),
|
|
378
|
+
value: z
|
|
379
|
+
.union([z.string(), z.number(), z.boolean()])
|
|
380
|
+
.describe("Value; coerced per property type (checkbox->bool, number, date YYYY-MM-DD, text)"),
|
|
381
|
+
},
|
|
382
|
+
}, setDocPropertyHandler);
|
|
383
|
+
// ---------------------------------------------------------------------------
|
|
384
|
+
// clear_doc_property
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
/** Handle `clear_doc_property`: remove a doc's value for a property (by id or name). */
|
|
387
|
+
const clearDocPropertyHandler = async (parsed) => {
|
|
388
|
+
const workspaceId = requireWorkspaceId(parsed.workspaceId);
|
|
389
|
+
const { endpoint, cookie, bearer } = getCookieAndEndpoint();
|
|
390
|
+
const socket = await connectWorkspaceSocket(wsUrlFromGraphQLEndpoint(endpoint), cookie, bearer);
|
|
391
|
+
try {
|
|
392
|
+
await joinWorkspace(socket, workspaceId);
|
|
393
|
+
const { doc: infoDoc } = await loadSubdoc(socket, workspaceId, CUSTOM_PROPERTY_INFO_GUID);
|
|
394
|
+
const defs = readPropertyDefinitions(infoDoc);
|
|
395
|
+
const def = resolveDefinition(defs, parsed.property);
|
|
396
|
+
// Allow clearing by raw id even if the definition was already deleted.
|
|
397
|
+
const propertyId = def?.id ?? parsed.property;
|
|
398
|
+
const { doc, prevSV } = await loadSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID);
|
|
399
|
+
let cleared = false;
|
|
400
|
+
if (doc.share.has(parsed.docId)) {
|
|
401
|
+
const record = doc.getMap(parsed.docId);
|
|
402
|
+
const key = CUSTOM_PREFIX + propertyId;
|
|
403
|
+
if (record.has(key)) {
|
|
404
|
+
record.delete(key);
|
|
405
|
+
cleared = true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (cleared) {
|
|
409
|
+
await pushSubdoc(socket, workspaceId, DOC_PROPERTIES_GUID, doc, prevSV);
|
|
410
|
+
}
|
|
411
|
+
return text({ workspaceId, docId: parsed.docId, propertyId, cleared });
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
socket.disconnect();
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
server.registerTool("clear_doc_property", {
|
|
418
|
+
title: "Clear Document Property",
|
|
419
|
+
description: "Remove a custom property value from a document (property by id or name).",
|
|
420
|
+
inputSchema: {
|
|
421
|
+
workspaceId: WorkspaceId.optional(),
|
|
422
|
+
docId: DocId,
|
|
423
|
+
property: z.string().min(1).describe("Property id or name"),
|
|
424
|
+
},
|
|
425
|
+
}, clearDocPropertyHandler);
|
|
426
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
import { loadDoc, pushDocUpdate } from "../ws.js";
|
|
3
|
+
/** Build the sub-document guid that holds a workspace's explorer icons. */
|
|
4
|
+
export function explorerIconDocId(workspaceId) {
|
|
5
|
+
return `db$${workspaceId}$explorerIcon`;
|
|
6
|
+
}
|
|
7
|
+
/** Build the per-entity map key for a document. */
|
|
8
|
+
export function docIconKey(docId) {
|
|
9
|
+
return `doc:${docId}`;
|
|
10
|
+
}
|
|
11
|
+
/** Build the per-entity map key for an organize folder. */
|
|
12
|
+
export function folderIconKey(folderId) {
|
|
13
|
+
return `folder:${folderId}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Coerce user input into the stored icon shape (or null to clear).
|
|
17
|
+
*
|
|
18
|
+
* - A bare string is treated as an emoji shorthand → `{ type: "emoji", unicode }`.
|
|
19
|
+
* - A `{ type: "emoji", unicode }` or `{ type: "icon", name }` object is passed
|
|
20
|
+
* through after validation. Named-icon `name` values are not validated against
|
|
21
|
+
* AFFiNE's fixed icon set — they are written as-is.
|
|
22
|
+
* - `null` clears the icon.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeIconInput(input) {
|
|
25
|
+
if (input === null)
|
|
26
|
+
return null;
|
|
27
|
+
if (typeof input === "string") {
|
|
28
|
+
const unicode = input.trim();
|
|
29
|
+
if (!unicode)
|
|
30
|
+
throw new Error("icon emoji string must not be empty.");
|
|
31
|
+
return { type: "emoji", unicode };
|
|
32
|
+
}
|
|
33
|
+
if (input.type === "emoji") {
|
|
34
|
+
const unicode = input.unicode?.trim();
|
|
35
|
+
if (!unicode)
|
|
36
|
+
throw new Error("emoji icon requires a non-empty `unicode`.");
|
|
37
|
+
return { type: "emoji", unicode };
|
|
38
|
+
}
|
|
39
|
+
if (input.type === "icon") {
|
|
40
|
+
const name = input.name?.trim();
|
|
41
|
+
if (!name)
|
|
42
|
+
throw new Error("named icon requires a non-empty `name`.");
|
|
43
|
+
return { type: "icon", name };
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`Unsupported icon type: ${JSON.stringify(input.type)}.`);
|
|
46
|
+
}
|
|
47
|
+
async function loadExplorerIconDoc(socket, workspaceId) {
|
|
48
|
+
const snap = await loadDoc(socket, workspaceId, explorerIconDocId(workspaceId));
|
|
49
|
+
const doc = new Y.Doc();
|
|
50
|
+
if (snap.missing)
|
|
51
|
+
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
52
|
+
return doc;
|
|
53
|
+
}
|
|
54
|
+
function readIcon(doc, key) {
|
|
55
|
+
const map = doc.share.has(key) ? doc.getMap(key) : null;
|
|
56
|
+
if (!map || !map.has("icon"))
|
|
57
|
+
return null;
|
|
58
|
+
const raw = map.get("icon");
|
|
59
|
+
if (raw && typeof raw.toJSON === "function") {
|
|
60
|
+
return raw.toJSON();
|
|
61
|
+
}
|
|
62
|
+
return raw ?? null;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Set or clear the sidebar icon for a single explorer entity.
|
|
66
|
+
*
|
|
67
|
+
* Loads the workspace's `explorerIcon` sub-doc, mutates only the target entry,
|
|
68
|
+
* and pushes the minimal delta. Passing `icon = null` removes the `icon` field
|
|
69
|
+
* while preserving the entry's `id`.
|
|
70
|
+
*/
|
|
71
|
+
export async function setExplorerIcon(socket, workspaceId, key, icon) {
|
|
72
|
+
const doc = await loadExplorerIconDoc(socket, workspaceId);
|
|
73
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
74
|
+
const map = doc.getMap(key);
|
|
75
|
+
if (!map.has("id"))
|
|
76
|
+
map.set("id", key);
|
|
77
|
+
if (icon === null) {
|
|
78
|
+
if (map.has("icon"))
|
|
79
|
+
map.delete("icon");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
map.set("icon", icon);
|
|
83
|
+
}
|
|
84
|
+
const delta = Y.encodeStateAsUpdate(doc, prevSV);
|
|
85
|
+
await pushDocUpdate(socket, workspaceId, explorerIconDocId(workspaceId), Buffer.from(delta).toString("base64"));
|
|
86
|
+
return { key, icon };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Read the current sidebar icon for a single explorer entity.
|
|
90
|
+
* Returns `null` when no icon is set (or the entry does not exist).
|
|
91
|
+
*/
|
|
92
|
+
export async function getExplorerIcon(socket, workspaceId, key) {
|
|
93
|
+
const doc = await loadExplorerIconDoc(socket, workspaceId);
|
|
94
|
+
return { key, icon: readIcon(doc, key) };
|
|
95
|
+
}
|
package/docs/tool-reference.md
CHANGED
|
@@ -39,6 +39,8 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
|
|
|
39
39
|
| `create_folder` | Create a root or nested folder | Experimental |
|
|
40
40
|
| `create_workspace_blueprint` | Create a simple workspace folder blueprint | Good for structured onboarding setups |
|
|
41
41
|
| `rename_folder` | Rename a folder | Experimental |
|
|
42
|
+
| `update_folder_icon` | Set or clear a folder's sidebar icon (emoji or named icon) | Experimental |
|
|
43
|
+
| `get_folder_icon` | Read a folder's current sidebar icon | Experimental |
|
|
42
44
|
| `delete_folder` | Delete a folder recursively | Experimental and destructive |
|
|
43
45
|
| `move_organize_node` | Move a folder or link node | Experimental |
|
|
44
46
|
| `add_organize_link` | Add a doc, tag, or collection link under a folder | Experimental |
|
|
@@ -55,7 +57,7 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
|
|
|
55
57
|
| `search_docs` | Search titles with substring, prefix, or exact matching | Supports tag filter and updatedAt sorting |
|
|
56
58
|
| `list_docs_by_tag` | List documents with a specific tag | |
|
|
57
59
|
| `get_doc` | Read document metadata | |
|
|
58
|
-
| `read_doc` | Read block content and plain text snapshot | WebSocket-backed |
|
|
60
|
+
| `read_doc` | Read block content and plain text snapshot | WebSocket-backed; block rows include `linkedDocIds` for inline LinkedPage references |
|
|
59
61
|
| `get_capabilities` | Inspect the server's high-level authoring and fidelity capabilities | Useful for adaptive clients |
|
|
60
62
|
| `analyze_doc_fidelity` | Analyze how a document maps to Markdown and which native AFFiNE structures are lossy | Good before export or migration |
|
|
61
63
|
| `list_children` | List direct child docs linked from a document | |
|
|
@@ -83,6 +85,8 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
|
|
|
83
85
|
| Tool | Purpose | Notes |
|
|
84
86
|
| --- | --- | --- |
|
|
85
87
|
| `update_doc_title` | Rename a document in workspace metadata and in the page block | |
|
|
88
|
+
| `update_doc_icon` | Set or clear a document's sidebar icon (emoji or named icon) | |
|
|
89
|
+
| `get_doc_icon` | Read a document's current sidebar icon | |
|
|
86
90
|
| `append_block` | Append canonical block types with validation and placement control | Supports text, media, embeds, database, and edgeless blocks. `frame`/`edgeless_text`/`note` accept `x`/`y`/`width`/`height`. `note` with `text` auto-creates a child paragraph so it renders on the edgeless canvas. |
|
|
87
91
|
| `create_semantic_page` | Create an AFFiNE-native page with an intentional section skeleton and native block composition | High-level authoring helper |
|
|
88
92
|
| `append_semantic_section` | Append a semantic section to an existing page by heading title | High-level authoring helper |
|
|
@@ -97,6 +101,16 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
|
|
|
97
101
|
| `add_tag_to_doc` | Attach a tag to a document | |
|
|
98
102
|
| `remove_tag_from_doc` | Detach a tag from a document | |
|
|
99
103
|
|
|
104
|
+
### Custom properties
|
|
105
|
+
|
|
106
|
+
| Tool | Purpose | Notes |
|
|
107
|
+
| --- | --- | --- |
|
|
108
|
+
| `list_doc_properties` | List workspace custom-property definitions and a document's current values | WebSocket-backed; reads the `db$docProperties` / `db$docCustomPropertyInfo` sub-docs |
|
|
109
|
+
| `create_custom_property` | Create a workspace-wide custom property definition | Types: `text`, `number`, `checkbox`, `date`. Returns the `propertyId` |
|
|
110
|
+
| `delete_custom_property` | Soft-delete a custom property definition by id or name | Destructive; existing values are hidden |
|
|
111
|
+
| `set_doc_property` | Set a document's custom property value by property id or name | Value validated per type (`checkbox` boolean, `number`, `date` `YYYY-MM-DD`, `text`) |
|
|
112
|
+
| `clear_doc_property` | Remove a custom property value from a document | |
|
|
113
|
+
|
|
100
114
|
### Markdown export
|
|
101
115
|
|
|
102
116
|
| Tool | Purpose | Notes |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "affine-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Model Context Protocol server for AFFiNE - enables AI assistants to interact with AFFiNE workspaces, documents, and collaboration features.",
|
|
@@ -43,10 +43,14 @@
|
|
|
43
43
|
"test:e2e": "bash tests/run-e2e.sh",
|
|
44
44
|
"test:db-create": "node tests/test-database-creation.mjs",
|
|
45
45
|
"test:db-cells": "node tests/test-database-cells.mjs",
|
|
46
|
+
"test:db-linked-doc": "node tests/test-database-linked-doc.mjs",
|
|
46
47
|
"test:db-ui-rows": "node tests/test-database-ui-rows.mjs",
|
|
47
48
|
"test:db-schema": "node tests/test-database-schema.mjs",
|
|
48
49
|
"test:data-view": "node tests/test-data-view.mjs",
|
|
49
50
|
"test:doc-discovery": "node tests/test-doc-discovery.mjs",
|
|
51
|
+
"test:doc-properties": "node tests/test-doc-properties.mjs",
|
|
52
|
+
"test:icons": "node tests/test-icons.mjs",
|
|
53
|
+
"test:read-doc-linked-refs": "node tests/test-read-doc-linked-refs.mjs",
|
|
50
54
|
"test:find-doc-by-title": "node tests/test-find-doc-by-title.mjs",
|
|
51
55
|
"test:create-placement": "node tests/test-create-placement.mjs",
|
|
52
56
|
"test:surface-elements": "node tests/test-surface-elements.mjs",
|
package/tool-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.
|
|
2
|
+
"version": "2.3.0",
|
|
3
3
|
"tools": [
|
|
4
4
|
"add_database_column",
|
|
5
5
|
"add_database_row",
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
"append_markdown",
|
|
13
13
|
"append_semantic_section",
|
|
14
14
|
"cleanup_blobs",
|
|
15
|
+
"clear_doc_property",
|
|
15
16
|
"compose_database_from_intent",
|
|
16
17
|
"create_collection",
|
|
17
18
|
"create_comment",
|
|
19
|
+
"create_custom_property",
|
|
18
20
|
"create_doc",
|
|
19
21
|
"create_doc_from_markdown",
|
|
20
22
|
"create_folder",
|
|
@@ -27,6 +29,7 @@
|
|
|
27
29
|
"delete_block",
|
|
28
30
|
"delete_collection",
|
|
29
31
|
"delete_comment",
|
|
32
|
+
"delete_custom_property",
|
|
30
33
|
"delete_database_row",
|
|
31
34
|
"delete_doc",
|
|
32
35
|
"delete_folder",
|
|
@@ -40,7 +43,9 @@
|
|
|
40
43
|
"get_capabilities",
|
|
41
44
|
"get_collection",
|
|
42
45
|
"get_doc",
|
|
46
|
+
"get_doc_icon",
|
|
43
47
|
"get_edgeless_canvas",
|
|
48
|
+
"get_folder_icon",
|
|
44
49
|
"get_orphan_docs",
|
|
45
50
|
"get_workspace",
|
|
46
51
|
"inspect_template_structure",
|
|
@@ -49,6 +54,7 @@
|
|
|
49
54
|
"list_children",
|
|
50
55
|
"list_collections",
|
|
51
56
|
"list_comments",
|
|
57
|
+
"list_doc_properties",
|
|
52
58
|
"list_docs",
|
|
53
59
|
"list_docs_by_tag",
|
|
54
60
|
"list_histories",
|
|
@@ -73,13 +79,16 @@
|
|
|
73
79
|
"revoke_access_token",
|
|
74
80
|
"revoke_doc",
|
|
75
81
|
"search_docs",
|
|
82
|
+
"set_doc_property",
|
|
76
83
|
"sign_in",
|
|
77
84
|
"update_collection",
|
|
78
85
|
"update_collection_rules",
|
|
79
86
|
"update_comment",
|
|
80
87
|
"update_database_row",
|
|
88
|
+
"update_doc_icon",
|
|
81
89
|
"update_doc_title",
|
|
82
90
|
"update_edgeless_block",
|
|
91
|
+
"update_folder_icon",
|
|
83
92
|
"update_frame_children",
|
|
84
93
|
"update_profile",
|
|
85
94
|
"update_settings",
|