affine-mcp-server 2.2.0 → 2.4.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 +3 -3
- package/dist/index.js +2 -0
- package/dist/toolSurface.js +14 -0
- package/dist/tools/docs.js +229 -30
- package/dist/tools/icons.js +125 -0
- package/dist/util/explorerIcon.js +95 -0
- package/docs/tool-reference.md +5 -0
- package/package.json +4 -2
- package/tool-manifest.json +6 -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 95 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.4.0: Added `delete_tag` for workspace tag cleanup and surfaced `inTrash` across document listing and hierarchy tools.
|
|
52
52
|
|
|
53
53
|
## Choose Your Path
|
|
54
54
|
| Goal | Start here |
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { loginWithPassword } from "./auth.js";
|
|
|
15
15
|
import { registerAuthTools } from "./tools/auth.js";
|
|
16
16
|
import { registerOrganizeTools } from "./tools/organize.js";
|
|
17
17
|
import { registerPropertyTools } from "./tools/properties.js";
|
|
18
|
+
import { registerIconTools } from "./tools/icons.js";
|
|
18
19
|
import { runCli } from "./cli.js";
|
|
19
20
|
import { startHttpMcpServer } from "./sse.js";
|
|
20
21
|
import { existsSync } from "fs";
|
|
@@ -167,6 +168,7 @@ async function buildServer() {
|
|
|
167
168
|
registerHistoryTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|
|
168
169
|
registerOrganizeTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|
|
169
170
|
registerPropertyTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|
|
171
|
+
registerIconTools(server, gql, { workspaceId: config.defaultWorkspaceId });
|
|
170
172
|
registerUserTools(server, gql);
|
|
171
173
|
registerUserCRUDTools(server, gql);
|
|
172
174
|
if (config.authMode !== "oauth") {
|
package/dist/toolSurface.js
CHANGED
|
@@ -33,6 +33,7 @@ const ALL_TOOLS = [
|
|
|
33
33
|
"delete_folder",
|
|
34
34
|
"delete_organize_link",
|
|
35
35
|
"delete_surface_element",
|
|
36
|
+
"delete_tag",
|
|
36
37
|
"delete_workspace",
|
|
37
38
|
"export_doc_markdown",
|
|
38
39
|
"export_with_fidelity_report",
|
|
@@ -41,7 +42,9 @@ const ALL_TOOLS = [
|
|
|
41
42
|
"get_capabilities",
|
|
42
43
|
"get_collection",
|
|
43
44
|
"get_doc",
|
|
45
|
+
"get_doc_icon",
|
|
44
46
|
"get_edgeless_canvas",
|
|
47
|
+
"get_folder_icon",
|
|
45
48
|
"get_orphan_docs",
|
|
46
49
|
"get_workspace",
|
|
47
50
|
"inspect_template_structure",
|
|
@@ -81,8 +84,10 @@ const ALL_TOOLS = [
|
|
|
81
84
|
"update_collection_rules",
|
|
82
85
|
"update_comment",
|
|
83
86
|
"update_database_row",
|
|
87
|
+
"update_doc_icon",
|
|
84
88
|
"update_doc_title",
|
|
85
89
|
"update_edgeless_block",
|
|
90
|
+
"update_folder_icon",
|
|
86
91
|
"update_frame_children",
|
|
87
92
|
"update_profile",
|
|
88
93
|
"update_settings",
|
|
@@ -125,6 +130,7 @@ const TOOL_GROUPS = {
|
|
|
125
130
|
delete_folder: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
|
|
126
131
|
delete_organize_link: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
|
|
127
132
|
delete_surface_element: ["docs", "docs.edgeless", "docs.surface", "docs.write", "destructive", "write"],
|
|
133
|
+
delete_tag: ["docs", "docs.tags", "docs.write", "destructive", "write"],
|
|
128
134
|
delete_workspace: ["workspaces", "workspaces.write", "admin", "destructive", "write"],
|
|
129
135
|
export_doc_markdown: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
|
|
130
136
|
export_with_fidelity_report: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
|
|
@@ -133,7 +139,9 @@ const TOOL_GROUPS = {
|
|
|
133
139
|
get_capabilities: ["docs", "docs.read", "read"],
|
|
134
140
|
get_collection: ["organize", "organize.collections", "organize.read", "read"],
|
|
135
141
|
get_doc: ["docs", "docs.read", "read"],
|
|
142
|
+
get_doc_icon: ["docs", "docs.read", "read"],
|
|
136
143
|
get_edgeless_canvas: ["docs", "docs.edgeless", "docs.surface", "docs.read", "read"],
|
|
144
|
+
get_folder_icon: ["organize", "organize.folders", "organize.read", "experimental", "read"],
|
|
137
145
|
get_orphan_docs: ["docs", "docs.tree", "docs.read", "read"],
|
|
138
146
|
get_workspace: ["workspaces", "workspaces.read", "read"],
|
|
139
147
|
inspect_template_structure: ["docs", "docs.template", "docs.read", "read"],
|
|
@@ -173,8 +181,10 @@ const TOOL_GROUPS = {
|
|
|
173
181
|
update_collection_rules: ["organize", "organize.collections", "organize.write", "write"],
|
|
174
182
|
update_comment: ["comments", "comments.write", "write"],
|
|
175
183
|
update_database_row: ["docs", "docs.database", "docs.write", "write"],
|
|
184
|
+
update_doc_icon: ["docs", "docs.write", "write"],
|
|
176
185
|
update_doc_title: ["docs", "docs.write", "write"],
|
|
177
186
|
update_edgeless_block: ["docs", "docs.edgeless", "docs.write", "write"],
|
|
187
|
+
update_folder_icon: ["organize", "organize.folders", "organize.write", "experimental", "write"],
|
|
178
188
|
update_frame_children: ["docs", "docs.edgeless", "docs.write", "write"],
|
|
179
189
|
update_profile: ["users", "users.write", "admin", "write"],
|
|
180
190
|
update_settings: ["users", "users.write", "admin", "write"],
|
|
@@ -191,7 +201,9 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
191
201
|
"get_capabilities",
|
|
192
202
|
"get_collection",
|
|
193
203
|
"get_doc",
|
|
204
|
+
"get_doc_icon",
|
|
194
205
|
"get_edgeless_canvas",
|
|
206
|
+
"get_folder_icon",
|
|
195
207
|
"get_orphan_docs",
|
|
196
208
|
"get_workspace",
|
|
197
209
|
"inspect_template_structure",
|
|
@@ -228,6 +240,7 @@ const CORE_TOOLS = new Set([
|
|
|
228
240
|
"find_doc_by_title",
|
|
229
241
|
"get_capabilities",
|
|
230
242
|
"get_doc",
|
|
243
|
+
"get_doc_icon",
|
|
231
244
|
"get_workspace",
|
|
232
245
|
"list_children",
|
|
233
246
|
"list_docs",
|
|
@@ -242,6 +255,7 @@ const CORE_TOOLS = new Set([
|
|
|
242
255
|
"search_docs",
|
|
243
256
|
"sign_in",
|
|
244
257
|
"update_database_row",
|
|
258
|
+
"update_doc_icon",
|
|
245
259
|
"update_doc_title",
|
|
246
260
|
]);
|
|
247
261
|
const AUTHORING_EXCLUDED_GROUPS = new Set([
|
package/dist/tools/docs.js
CHANGED
|
@@ -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");
|
|
@@ -416,6 +463,31 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
416
463
|
optionsArray.push([optionMap]);
|
|
417
464
|
return { option, created: true };
|
|
418
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* Resolve the single tag option targeted for deletion. Matches a tag id
|
|
468
|
+
* first (ids are unique), then falls back to a case-insensitive name match.
|
|
469
|
+
* Throws when no tag matches, or when a name is shared by several tags so the
|
|
470
|
+
* caller can re-run with a specific id rather than deleting the wrong one.
|
|
471
|
+
*/
|
|
472
|
+
function findTagOptionForDeletion(meta, tag) {
|
|
473
|
+
const normalized = normalizeTag(tag);
|
|
474
|
+
const { options, byId } = getWorkspaceTagOptionMaps(meta);
|
|
475
|
+
const byIdMatch = byId.get(normalized);
|
|
476
|
+
if (byIdMatch) {
|
|
477
|
+
return { option: byIdMatch, matchedBy: "id" };
|
|
478
|
+
}
|
|
479
|
+
const lower = normalized.toLocaleLowerCase();
|
|
480
|
+
const valueMatches = options.filter((option) => option.value.toLocaleLowerCase() === lower);
|
|
481
|
+
if (valueMatches.length === 0) {
|
|
482
|
+
throw new Error(`Tag "${normalized}" was not found in workspace tag options.`);
|
|
483
|
+
}
|
|
484
|
+
if (valueMatches.length > 1) {
|
|
485
|
+
const candidates = valueMatches.map((option) => `${option.id} ("${option.value}")`).join(", ");
|
|
486
|
+
throw new Error(`Tag name "${normalized}" is ambiguous; ${valueMatches.length} tags share this name. ` +
|
|
487
|
+
`Re-run delete_tag with a specific tag id: ${candidates}`);
|
|
488
|
+
}
|
|
489
|
+
return { option: valueMatches[0], matchedBy: "value" };
|
|
490
|
+
}
|
|
419
491
|
function collectMatchingTagIndexes(tags, requestedTag, option, ignoreCase) {
|
|
420
492
|
const normalizedRequested = ignoreCase ? requestedTag.toLocaleLowerCase() : requestedTag;
|
|
421
493
|
const normalizedOptionId = option
|
|
@@ -504,12 +576,19 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
504
576
|
const title = value.get("title");
|
|
505
577
|
const createDate = value.get("createDate");
|
|
506
578
|
const updatedDate = value.get("updatedDate");
|
|
579
|
+
const inTrashRaw = value.get("inTrash");
|
|
580
|
+
const trashRaw = value.get("trash");
|
|
581
|
+
const trashDate = value.get("trashDate");
|
|
582
|
+
const inTrash = typeof inTrashRaw === "boolean" ? inTrashRaw
|
|
583
|
+
: typeof trashRaw === "boolean" ? trashRaw
|
|
584
|
+
: (typeof trashDate === "number" && trashDate > 0);
|
|
507
585
|
entries.push({
|
|
508
586
|
index,
|
|
509
587
|
id,
|
|
510
588
|
title: typeof title === "string" ? title : null,
|
|
511
589
|
createDate: typeof createDate === "number" ? createDate : null,
|
|
512
590
|
updatedDate: typeof updatedDate === "number" ? updatedDate : null,
|
|
591
|
+
inTrash,
|
|
513
592
|
entry: value,
|
|
514
593
|
tagsArray: getTagArray(value),
|
|
515
594
|
});
|
|
@@ -3130,6 +3209,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3130
3209
|
const docs = data.workspace.docs;
|
|
3131
3210
|
const tagsByDocId = new Map();
|
|
3132
3211
|
const titlesByDocId = new Map();
|
|
3212
|
+
const inTrashByDocId = new Map();
|
|
3133
3213
|
let workspacePageCount = null;
|
|
3134
3214
|
let workspacePageIds = null;
|
|
3135
3215
|
const deletedDocIds = new Set();
|
|
@@ -3154,6 +3234,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3154
3234
|
}
|
|
3155
3235
|
const tagEntries = getStringArray(page.tagsArray);
|
|
3156
3236
|
tagsByDocId.set(page.id, resolveTagLabels(tagEntries, byId));
|
|
3237
|
+
inTrashByDocId.set(page.id, page.inTrash);
|
|
3157
3238
|
}
|
|
3158
3239
|
}
|
|
3159
3240
|
const graphEdges = Array.isArray(docs?.edges) ? docs.edges : [];
|
|
@@ -3192,6 +3273,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3192
3273
|
...node,
|
|
3193
3274
|
title: titlesByDocId.get(node.id) || node.title,
|
|
3194
3275
|
tags: tagsByDocId.get(node.id) || [],
|
|
3276
|
+
inTrash: inTrashByDocId.get(node.id) ?? false,
|
|
3195
3277
|
},
|
|
3196
3278
|
};
|
|
3197
3279
|
})
|
|
@@ -3225,7 +3307,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3225
3307
|
};
|
|
3226
3308
|
server.registerTool("list_docs", {
|
|
3227
3309
|
title: "List Documents",
|
|
3228
|
-
description: "List documents in a workspace (GraphQL).",
|
|
3310
|
+
description: "List documents in a workspace (GraphQL). Each doc includes an inTrash flag.",
|
|
3229
3311
|
inputSchema: {
|
|
3230
3312
|
workspaceId: z.string().describe("Workspace ID (optional if default set).").optional(),
|
|
3231
3313
|
first: z.number().optional(),
|
|
@@ -3354,6 +3436,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3354
3436
|
updatedAt: updatedTimestamp > 0 ? new Date(updatedTimestamp).toISOString() : null,
|
|
3355
3437
|
updatedTimestamp,
|
|
3356
3438
|
url: `${baseUrl}/workspace/${workspaceId}/${page.id}`,
|
|
3439
|
+
inTrash: page.inTrash,
|
|
3357
3440
|
rank,
|
|
3358
3441
|
};
|
|
3359
3442
|
})
|
|
@@ -3382,6 +3465,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3382
3465
|
tags: entry.tags,
|
|
3383
3466
|
updatedAt: entry.updatedAt,
|
|
3384
3467
|
url: entry.url,
|
|
3468
|
+
inTrash: entry.inTrash,
|
|
3385
3469
|
}));
|
|
3386
3470
|
return text({
|
|
3387
3471
|
query: parsed.query,
|
|
@@ -3399,7 +3483,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3399
3483
|
};
|
|
3400
3484
|
server.registerTool("search_docs", {
|
|
3401
3485
|
title: "Search Documents by Title",
|
|
3402
|
-
description: "Fast search for documents by title using workspace metadata. Much faster than exporting each doc. Returns docId, title,
|
|
3486
|
+
description: "Fast search for documents by title using workspace metadata. Much faster than exporting each doc. Returns docId, title, direct URL, and inTrash for each match.",
|
|
3403
3487
|
inputSchema: {
|
|
3404
3488
|
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)."),
|
|
3405
3489
|
query: z.string().describe("Search query — matched case-insensitively against doc titles."),
|
|
@@ -3463,6 +3547,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3463
3547
|
title: pageTitle,
|
|
3464
3548
|
createdAt: page.createDate ? new Date(page.createDate).toISOString() : null,
|
|
3465
3549
|
updatedAt: updatedTimestamp ? new Date(updatedTimestamp).toISOString() : null,
|
|
3550
|
+
inTrash: page.inTrash,
|
|
3466
3551
|
});
|
|
3467
3552
|
}
|
|
3468
3553
|
return text({
|
|
@@ -3484,7 +3569,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3484
3569
|
"Reads workspace metadata — fast, no per-doc fetch. " +
|
|
3485
3570
|
"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
3571
|
"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 }.",
|
|
3572
|
+
"Returns: { query, caseInsensitive, matches: [{ id, title, createdAt, updatedAt, inTrash }], workspaceDocCount, truncated }.",
|
|
3488
3573
|
inputSchema: {
|
|
3489
3574
|
workspaceId: z.string().optional().describe("Workspace ID (optional if AFFINE_WORKSPACE_ID is set)."),
|
|
3490
3575
|
title: z.string().min(1).describe("The exact title to match."),
|
|
@@ -3531,6 +3616,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3531
3616
|
updatedDate: page.updatedDate,
|
|
3532
3617
|
tags,
|
|
3533
3618
|
rawTags,
|
|
3619
|
+
inTrash: page.inTrash,
|
|
3534
3620
|
};
|
|
3535
3621
|
})
|
|
3536
3622
|
.filter((page) => hasTag(page.tags, tag, ignoreCase) || hasTag(page.rawTags, tag, ignoreCase))
|
|
@@ -3549,7 +3635,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3549
3635
|
};
|
|
3550
3636
|
server.registerTool("list_docs_by_tag", {
|
|
3551
3637
|
title: "List Documents By Tag",
|
|
3552
|
-
description: "List documents that contain the requested tag.",
|
|
3638
|
+
description: "List documents that contain the requested tag. Each doc includes an inTrash flag.",
|
|
3553
3639
|
inputSchema: {
|
|
3554
3640
|
workspaceId: WorkspaceId.optional(),
|
|
3555
3641
|
tag: z.string().min(1).describe("Tag name"),
|
|
@@ -3743,6 +3829,121 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3743
3829
|
tag: z.string().min(1).describe("Tag name"),
|
|
3744
3830
|
},
|
|
3745
3831
|
}, removeTagFromDocHandler);
|
|
3832
|
+
/**
|
|
3833
|
+
* Delete a workspace-level tag and detach it from every document that
|
|
3834
|
+
* references it, mirroring AFFiNE's TagStore.removeTagOption. Resolves the
|
|
3835
|
+
* tag by id or name (ambiguous names are rejected), removes the option from
|
|
3836
|
+
* meta.properties.tags.options, strips the tag id from each page's tag array,
|
|
3837
|
+
* then syncs each affected document's own metadata. All workspace-root edits
|
|
3838
|
+
* are applied to an in-memory Y.Doc and pushed as a single delta, so a failed
|
|
3839
|
+
* push leaves the server unchanged and the operation safely retriable.
|
|
3840
|
+
*/
|
|
3841
|
+
const deleteTagHandler = async (parsed) => {
|
|
3842
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3843
|
+
if (!workspaceId) {
|
|
3844
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
3845
|
+
}
|
|
3846
|
+
const tag = normalizeTag(parsed.tag);
|
|
3847
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3848
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3849
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3850
|
+
try {
|
|
3851
|
+
await joinWorkspace(socket, workspaceId);
|
|
3852
|
+
const wsSnapshot = await loadDoc(socket, workspaceId, workspaceId);
|
|
3853
|
+
if (!wsSnapshot.missing) {
|
|
3854
|
+
throw new Error(`Workspace root document not found for workspace ${workspaceId}`);
|
|
3855
|
+
}
|
|
3856
|
+
const wsDoc = new Y.Doc();
|
|
3857
|
+
Y.applyUpdate(wsDoc, Buffer.from(wsSnapshot.missing, "base64"));
|
|
3858
|
+
const wsPrevSV = Y.encodeStateVector(wsDoc);
|
|
3859
|
+
const wsMeta = wsDoc.getMap("meta");
|
|
3860
|
+
const { option } = findTagOptionForDeletion(wsMeta, tag);
|
|
3861
|
+
// Step 1: drop the tag option itself from the workspace tag registry.
|
|
3862
|
+
const optionsArray = getWorkspaceTagOptionsArray(wsMeta);
|
|
3863
|
+
let optionRemoved = false;
|
|
3864
|
+
if (optionsArray) {
|
|
3865
|
+
const optionIndexes = [];
|
|
3866
|
+
optionsArray.forEach((raw, index) => {
|
|
3867
|
+
const parsedOption = parseWorkspaceTagOption(raw);
|
|
3868
|
+
if (parsedOption && parsedOption.id === option.id) {
|
|
3869
|
+
optionIndexes.push(index);
|
|
3870
|
+
}
|
|
3871
|
+
});
|
|
3872
|
+
optionRemoved = deleteArrayIndexes(optionsArray, optionIndexes);
|
|
3873
|
+
}
|
|
3874
|
+
// Step 2: strip the tag id from every page entry that still references it.
|
|
3875
|
+
// Match by id only (case-sensitive), mirroring AFFiNE's removeTagOption
|
|
3876
|
+
// (`t !== id`): doc tags are stored as canonical ids, and matching by
|
|
3877
|
+
// value could clobber a same-name sibling tag's references.
|
|
3878
|
+
const affectedDocIds = [];
|
|
3879
|
+
for (const page of getWorkspacePageEntries(wsMeta)) {
|
|
3880
|
+
const pageTags = page.tagsArray;
|
|
3881
|
+
if (!pageTags) {
|
|
3882
|
+
continue;
|
|
3883
|
+
}
|
|
3884
|
+
const indexes = collectMatchingTagIndexes(pageTags, option.id, null, false);
|
|
3885
|
+
if (deleteArrayIndexes(pageTags, indexes)) {
|
|
3886
|
+
affectedDocIds.push(page.id);
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
if (optionRemoved || affectedDocIds.length > 0) {
|
|
3890
|
+
const wsDelta = Y.encodeStateAsUpdate(wsDoc, wsPrevSV);
|
|
3891
|
+
await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(wsDelta).toString("base64"));
|
|
3892
|
+
}
|
|
3893
|
+
// Step 3: mirror the cleanup in each affected document's own metadata.
|
|
3894
|
+
let docMetaSynced = 0;
|
|
3895
|
+
const warnings = [];
|
|
3896
|
+
// The workspace registry is the source of truth and has already been
|
|
3897
|
+
// updated; per-document metadata is a secondary sync. Treat each doc as
|
|
3898
|
+
// best-effort so one failing push degrades to a warning instead of
|
|
3899
|
+
// throwing and leaving a non-retriable partial state.
|
|
3900
|
+
for (const docId of affectedDocIds) {
|
|
3901
|
+
try {
|
|
3902
|
+
const docSnapshot = await loadDoc(socket, workspaceId, docId);
|
|
3903
|
+
if (!docSnapshot.missing) {
|
|
3904
|
+
warnings.push(`Document ${docId} snapshot not found; workspace tag map was updated only.`);
|
|
3905
|
+
continue;
|
|
3906
|
+
}
|
|
3907
|
+
const doc = new Y.Doc();
|
|
3908
|
+
Y.applyUpdate(doc, Buffer.from(docSnapshot.missing, "base64"));
|
|
3909
|
+
const docPrevSV = Y.encodeStateVector(doc);
|
|
3910
|
+
const docTags = getTagArray(doc.getMap("meta"));
|
|
3911
|
+
if (docTags) {
|
|
3912
|
+
const docIndexes = collectMatchingTagIndexes(docTags, option.id, null, false);
|
|
3913
|
+
if (deleteArrayIndexes(docTags, docIndexes)) {
|
|
3914
|
+
const docDelta = Y.encodeStateAsUpdate(doc, docPrevSV);
|
|
3915
|
+
await pushDocUpdate(socket, workspaceId, docId, Buffer.from(docDelta).toString("base64"));
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
docMetaSynced += 1;
|
|
3919
|
+
}
|
|
3920
|
+
catch (err) {
|
|
3921
|
+
warnings.push(`Document ${docId} metadata sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
return text({
|
|
3925
|
+
workspaceId,
|
|
3926
|
+
tag,
|
|
3927
|
+
tagId: option.id,
|
|
3928
|
+
value: option.value,
|
|
3929
|
+
deleted: optionRemoved,
|
|
3930
|
+
affectedDocs: affectedDocIds.length,
|
|
3931
|
+
docMetaSynced,
|
|
3932
|
+
warnings,
|
|
3933
|
+
});
|
|
3934
|
+
}
|
|
3935
|
+
finally {
|
|
3936
|
+
socket.disconnect();
|
|
3937
|
+
}
|
|
3938
|
+
};
|
|
3939
|
+
server.registerTool("delete_tag", {
|
|
3940
|
+
title: "Delete Tag",
|
|
3941
|
+
description: "Delete a workspace-level tag and remove it from every document that references it. Accepts a tag id or name; an ambiguous name is rejected with the candidate ids.",
|
|
3942
|
+
inputSchema: {
|
|
3943
|
+
workspaceId: WorkspaceId.optional(),
|
|
3944
|
+
tag: z.string().min(1).describe("Tag id or name to delete"),
|
|
3945
|
+
},
|
|
3946
|
+
}, deleteTagHandler);
|
|
3746
3947
|
const getDocHandler = async (parsed) => {
|
|
3747
3948
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3748
3949
|
if (!workspaceId) {
|
|
@@ -4773,6 +4974,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4773
4974
|
Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
|
|
4774
4975
|
const pages = getWorkspacePageEntries(wsDoc.getMap("meta"));
|
|
4775
4976
|
const titleById = new Map(pages.map(p => [p.id, p.title ?? "Untitled"]));
|
|
4977
|
+
const trashById = new Map(pages.map(p => [p.id, p.inTrash]));
|
|
4776
4978
|
const childrenOf = new Map();
|
|
4777
4979
|
const allChildren = new Set();
|
|
4778
4980
|
for (const page of pages) {
|
|
@@ -4783,13 +4985,8 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4783
4985
|
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4784
4986
|
const blocks = doc.getMap("blocks");
|
|
4785
4987
|
const kids = [];
|
|
4786
|
-
for (const
|
|
4787
|
-
if (
|
|
4788
|
-
continue;
|
|
4789
|
-
if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
|
|
4790
|
-
continue;
|
|
4791
|
-
const pid = raw.get("prop:pageId");
|
|
4792
|
-
if (typeof pid === "string" && pid && titleById.has(pid)) {
|
|
4988
|
+
for (const pid of collectLinkedChildIds(blocks)) {
|
|
4989
|
+
if (titleById.has(pid) && !kids.includes(pid)) {
|
|
4793
4990
|
kids.push(pid);
|
|
4794
4991
|
allChildren.add(pid);
|
|
4795
4992
|
}
|
|
@@ -4802,6 +4999,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4802
4999
|
const buildNode = (id, depth) => ({
|
|
4803
5000
|
docId: id, title: titleById.get(id) ?? "Untitled",
|
|
4804
5001
|
url: `${baseUrl}/workspace/${workspaceId}/${id}`,
|
|
5002
|
+
inTrash: trashById.get(id) ?? false,
|
|
4805
5003
|
children: depth < maxDepth ? (childrenOf.get(id) ?? []).map(cid => buildNode(cid, depth + 1)) : [],
|
|
4806
5004
|
});
|
|
4807
5005
|
return text({ workspaceId, totalDocs: pages.length, rootCount: roots.length, tree: roots.map(id => buildNode(id, 0)) });
|
|
@@ -4812,7 +5010,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4812
5010
|
};
|
|
4813
5011
|
server.registerTool("list_workspace_tree", {
|
|
4814
5012
|
title: "List Workspace Tree",
|
|
4815
|
-
description: "Returns the full document hierarchy as a tree (roots → children → grandchildren). Use depth to limit nesting (default: 3). Note: loads all docs — may be slow on large workspaces.",
|
|
5013
|
+
description: "Returns the full document hierarchy as a tree (roots → children → grandchildren). Use depth to limit nesting (default: 3). Note: loads all docs — may be slow on large workspaces. Each node includes an inTrash flag.",
|
|
4816
5014
|
inputSchema: {
|
|
4817
5015
|
workspaceId: z.string().optional(),
|
|
4818
5016
|
depth: z.number().optional().describe("Max nesting depth to return (default: 3)."),
|
|
@@ -4843,14 +5041,8 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4843
5041
|
const doc = new Y.Doc();
|
|
4844
5042
|
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4845
5043
|
const blocks = doc.getMap("blocks");
|
|
4846
|
-
for (const
|
|
4847
|
-
|
|
4848
|
-
continue;
|
|
4849
|
-
if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
|
|
4850
|
-
continue;
|
|
4851
|
-
const pageId = raw.get("prop:pageId");
|
|
4852
|
-
if (typeof pageId === "string" && pageId)
|
|
4853
|
-
allChildren.add(pageId);
|
|
5044
|
+
for (const pageId of collectLinkedChildIds(blocks)) {
|
|
5045
|
+
allChildren.add(pageId);
|
|
4854
5046
|
}
|
|
4855
5047
|
}
|
|
4856
5048
|
const baseUrl = (process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, "")).replace(/\/$/, "");
|
|
@@ -4860,6 +5052,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4860
5052
|
docId: p.id,
|
|
4861
5053
|
title: titleById.get(p.id) ?? "Untitled",
|
|
4862
5054
|
url: `${baseUrl}/workspace/${workspaceId}/${p.id}`,
|
|
5055
|
+
inTrash: p.inTrash,
|
|
4863
5056
|
}));
|
|
4864
5057
|
return text({ count: orphans.length, orphans });
|
|
4865
5058
|
}
|
|
@@ -4869,7 +5062,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4869
5062
|
};
|
|
4870
5063
|
server.registerTool("get_orphan_docs", {
|
|
4871
5064
|
title: "Get Orphan Documents",
|
|
4872
|
-
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).",
|
|
5065
|
+
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). Each doc includes an inTrash flag.",
|
|
4873
5066
|
inputSchema: { workspaceId: z.string().optional() },
|
|
4874
5067
|
}, getOrphanDocsHandler);
|
|
4875
5068
|
const listChildrenHandler = async (parsed) => {
|
|
@@ -4882,11 +5075,17 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4882
5075
|
try {
|
|
4883
5076
|
await joinWorkspace(socket, workspaceId);
|
|
4884
5077
|
const titleById = new Map();
|
|
5078
|
+
const trashById = new Map();
|
|
5079
|
+
const workspacePageIds = new Set();
|
|
5080
|
+
let hasWorkspaceMetadata = false;
|
|
4885
5081
|
const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
|
|
4886
5082
|
if (wsSnap.missing) {
|
|
5083
|
+
hasWorkspaceMetadata = true;
|
|
4887
5084
|
const wsDoc = new Y.Doc();
|
|
4888
5085
|
Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
|
|
4889
5086
|
for (const page of getWorkspacePageEntries(wsDoc.getMap("meta"))) {
|
|
5087
|
+
workspacePageIds.add(page.id);
|
|
5088
|
+
trashById.set(page.id, page.inTrash);
|
|
4890
5089
|
if (page.title)
|
|
4891
5090
|
titleById.set(page.id, page.title);
|
|
4892
5091
|
}
|
|
@@ -4898,16 +5097,16 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4898
5097
|
Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
|
|
4899
5098
|
const blocks = doc.getMap("blocks");
|
|
4900
5099
|
const children = [];
|
|
4901
|
-
|
|
4902
|
-
|
|
5100
|
+
const seen = new Set();
|
|
5101
|
+
for (const pageId of collectLinkedChildIds(blocks)) {
|
|
5102
|
+
if (hasWorkspaceMetadata && !workspacePageIds.has(pageId))
|
|
4903
5103
|
continue;
|
|
4904
|
-
if (
|
|
5104
|
+
if (seen.has(pageId))
|
|
4905
5105
|
continue;
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
}
|
|
5106
|
+
seen.add(pageId);
|
|
5107
|
+
children.push({ docId: pageId, title: titleById.get(pageId) ?? null,
|
|
5108
|
+
url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}`,
|
|
5109
|
+
inTrash: trashById.get(pageId) ?? false });
|
|
4911
5110
|
}
|
|
4912
5111
|
return text({ docId: parsed.docId, count: children.length, children });
|
|
4913
5112
|
}
|
|
@@ -4917,7 +5116,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4917
5116
|
};
|
|
4918
5117
|
server.registerTool("list_children", {
|
|
4919
5118
|
title: "List Document Children",
|
|
4920
|
-
description: "List the direct children of a document in the sidebar (embed_linked_doc blocks). Returns docId, title, and
|
|
5119
|
+
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, URL, and inTrash for each child.",
|
|
4921
5120
|
inputSchema: {
|
|
4922
5121
|
workspaceId: z.string().optional(),
|
|
4923
5122
|
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,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 |
|
|
@@ -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 |
|
|
@@ -96,6 +100,7 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
|
|
|
96
100
|
| `create_tag` | Create a reusable workspace-level tag | |
|
|
97
101
|
| `add_tag_to_doc` | Attach a tag to a document | |
|
|
98
102
|
| `remove_tag_from_doc` | Detach a tag from a document | |
|
|
103
|
+
| `delete_tag` | Delete a workspace tag and detach it from every document | Destructive; accepts a tag id or name, rejects an ambiguous name |
|
|
99
104
|
|
|
100
105
|
### Custom properties
|
|
101
106
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "affine-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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.",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"test:data-view": "node tests/test-data-view.mjs",
|
|
50
50
|
"test:doc-discovery": "node tests/test-doc-discovery.mjs",
|
|
51
51
|
"test:doc-properties": "node tests/test-doc-properties.mjs",
|
|
52
|
+
"test:icons": "node tests/test-icons.mjs",
|
|
52
53
|
"test:read-doc-linked-refs": "node tests/test-read-doc-linked-refs.mjs",
|
|
53
54
|
"test:find-doc-by-title": "node tests/test-find-doc-by-title.mjs",
|
|
54
55
|
"test:create-placement": "node tests/test-create-placement.mjs",
|
|
@@ -67,6 +68,7 @@
|
|
|
67
68
|
"test:create-doc-folder-placement": "node tests/test-create-doc-folder-placement.mjs",
|
|
68
69
|
"test:supporting-tools": "node tests/test-supporting-tools.mjs",
|
|
69
70
|
"test:tag-visibility": "node tests/test-tag-visibility.mjs",
|
|
71
|
+
"test:tag-deletion": "node tests/test-tag-deletion.mjs",
|
|
70
72
|
"test:markdown-roundtrip": "node tests/test-markdown-roundtrip.mjs",
|
|
71
73
|
"test:markdown-rich-text-import": "node tests/test-markdown-rich-text-import.mjs",
|
|
72
74
|
"test:playwright": "npx playwright test --config tests/playwright/playwright.config.ts",
|
|
@@ -98,7 +100,7 @@
|
|
|
98
100
|
"markdown-it": "^14.1.0",
|
|
99
101
|
"node-fetch": "^3.3.2",
|
|
100
102
|
"socket.io-client": "^4.8.1",
|
|
101
|
-
"undici": "^
|
|
103
|
+
"undici": "^6.27.0",
|
|
102
104
|
"yjs": "^13.6.27",
|
|
103
105
|
"zod": "^3.23.8"
|
|
104
106
|
},
|
package/tool-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.
|
|
2
|
+
"version": "2.4.0",
|
|
3
3
|
"tools": [
|
|
4
4
|
"add_database_column",
|
|
5
5
|
"add_database_row",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"delete_folder",
|
|
36
36
|
"delete_organize_link",
|
|
37
37
|
"delete_surface_element",
|
|
38
|
+
"delete_tag",
|
|
38
39
|
"delete_workspace",
|
|
39
40
|
"export_doc_markdown",
|
|
40
41
|
"export_with_fidelity_report",
|
|
@@ -43,7 +44,9 @@
|
|
|
43
44
|
"get_capabilities",
|
|
44
45
|
"get_collection",
|
|
45
46
|
"get_doc",
|
|
47
|
+
"get_doc_icon",
|
|
46
48
|
"get_edgeless_canvas",
|
|
49
|
+
"get_folder_icon",
|
|
47
50
|
"get_orphan_docs",
|
|
48
51
|
"get_workspace",
|
|
49
52
|
"inspect_template_structure",
|
|
@@ -83,8 +86,10 @@
|
|
|
83
86
|
"update_collection_rules",
|
|
84
87
|
"update_comment",
|
|
85
88
|
"update_database_row",
|
|
89
|
+
"update_doc_icon",
|
|
86
90
|
"update_doc_title",
|
|
87
91
|
"update_edgeless_block",
|
|
92
|
+
"update_folder_icon",
|
|
88
93
|
"update_frame_children",
|
|
89
94
|
"update_profile",
|
|
90
95
|
"update_settings",
|