affine-mcp-server 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A Model Context Protocol (MCP) server for AFFiNE. It exposes AFFiNE workspaces and documents to AI assistants over stdio (default) or HTTP (`/mcp`) and supports both AFFiNE Cloud and self-hosted deployments.
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.2.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.3.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
6
6
  [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-1.17.2-green)](https://github.com/modelcontextprotocol/typescript-sdk)
7
7
  [![CI](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml)
8
8
  [![License](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)
@@ -38,7 +38,7 @@ Highlights:
38
38
  - Supports AFFiNE Cloud and self-hosted AFFiNE instances
39
39
  - Supports stdio and HTTP transports
40
40
  - Supports token, cookie, and email/password authentication
41
- - Exposes 90 canonical MCP tools backed by AFFiNE GraphQL and WebSocket APIs
41
+ - Exposes 94 canonical MCP tools backed by AFFiNE GraphQL and WebSocket APIs
42
42
  - Includes semantic page composition, native template instantiation, database intent composition, capability and fidelity reporting, and workspace blueprint helpers
43
43
  - Includes Docker images, health probes, and end-to-end test coverage
44
44
 
@@ -48,7 +48,7 @@ Scope boundaries:
48
48
  - Browser-local workspaces stored only in local storage are not available through AFFiNE server APIs
49
49
  - AFFiNE Cloud requires API-token-based access for MCP usage; programmatic email/password sign-in is blocked by Cloudflare
50
50
 
51
- > New in v2.2.0: Added document custom-property tools, `read_doc` LinkedPage reference IDs, and table ordering fixes.
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 |
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") {
@@ -41,7 +41,9 @@ const ALL_TOOLS = [
41
41
  "get_capabilities",
42
42
  "get_collection",
43
43
  "get_doc",
44
+ "get_doc_icon",
44
45
  "get_edgeless_canvas",
46
+ "get_folder_icon",
45
47
  "get_orphan_docs",
46
48
  "get_workspace",
47
49
  "inspect_template_structure",
@@ -81,8 +83,10 @@ const ALL_TOOLS = [
81
83
  "update_collection_rules",
82
84
  "update_comment",
83
85
  "update_database_row",
86
+ "update_doc_icon",
84
87
  "update_doc_title",
85
88
  "update_edgeless_block",
89
+ "update_folder_icon",
86
90
  "update_frame_children",
87
91
  "update_profile",
88
92
  "update_settings",
@@ -133,7 +137,9 @@ const TOOL_GROUPS = {
133
137
  get_capabilities: ["docs", "docs.read", "read"],
134
138
  get_collection: ["organize", "organize.collections", "organize.read", "read"],
135
139
  get_doc: ["docs", "docs.read", "read"],
140
+ get_doc_icon: ["docs", "docs.read", "read"],
136
141
  get_edgeless_canvas: ["docs", "docs.edgeless", "docs.surface", "docs.read", "read"],
142
+ get_folder_icon: ["organize", "organize.folders", "organize.read", "experimental", "read"],
137
143
  get_orphan_docs: ["docs", "docs.tree", "docs.read", "read"],
138
144
  get_workspace: ["workspaces", "workspaces.read", "read"],
139
145
  inspect_template_structure: ["docs", "docs.template", "docs.read", "read"],
@@ -173,8 +179,10 @@ const TOOL_GROUPS = {
173
179
  update_collection_rules: ["organize", "organize.collections", "organize.write", "write"],
174
180
  update_comment: ["comments", "comments.write", "write"],
175
181
  update_database_row: ["docs", "docs.database", "docs.write", "write"],
182
+ update_doc_icon: ["docs", "docs.write", "write"],
176
183
  update_doc_title: ["docs", "docs.write", "write"],
177
184
  update_edgeless_block: ["docs", "docs.edgeless", "docs.write", "write"],
185
+ update_folder_icon: ["organize", "organize.folders", "organize.write", "experimental", "write"],
178
186
  update_frame_children: ["docs", "docs.edgeless", "docs.write", "write"],
179
187
  update_profile: ["users", "users.write", "admin", "write"],
180
188
  update_settings: ["users", "users.write", "admin", "write"],
@@ -191,7 +199,9 @@ const READ_ONLY_TOOLS = new Set([
191
199
  "get_capabilities",
192
200
  "get_collection",
193
201
  "get_doc",
202
+ "get_doc_icon",
194
203
  "get_edgeless_canvas",
204
+ "get_folder_icon",
195
205
  "get_orphan_docs",
196
206
  "get_workspace",
197
207
  "inspect_template_structure",
@@ -228,6 +238,7 @@ const CORE_TOOLS = new Set([
228
238
  "find_doc_by_title",
229
239
  "get_capabilities",
230
240
  "get_doc",
241
+ "get_doc_icon",
231
242
  "get_workspace",
232
243
  "list_children",
233
244
  "list_docs",
@@ -242,6 +253,7 @@ const CORE_TOOLS = new Set([
242
253
  "search_docs",
243
254
  "sign_in",
244
255
  "update_database_row",
256
+ "update_doc_icon",
245
257
  "update_doc_title",
246
258
  ]);
247
259
  const AUTHORING_EXCLUDED_GROUPS = new Set([
@@ -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");
@@ -4783,13 +4830,8 @@ export function registerDocTools(server, gql, defaults) {
4783
4830
  Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4784
4831
  const blocks = doc.getMap("blocks");
4785
4832
  const kids = [];
4786
- for (const [, raw] of blocks) {
4787
- if (!(raw instanceof Y.Map))
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)) {
4833
+ for (const pid of collectLinkedChildIds(blocks)) {
4834
+ if (titleById.has(pid) && !kids.includes(pid)) {
4793
4835
  kids.push(pid);
4794
4836
  allChildren.add(pid);
4795
4837
  }
@@ -4843,14 +4885,8 @@ export function registerDocTools(server, gql, defaults) {
4843
4885
  const doc = new Y.Doc();
4844
4886
  Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4845
4887
  const blocks = doc.getMap("blocks");
4846
- for (const [, raw] of blocks) {
4847
- if (!(raw instanceof Y.Map))
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);
4888
+ for (const pageId of collectLinkedChildIds(blocks)) {
4889
+ allChildren.add(pageId);
4854
4890
  }
4855
4891
  }
4856
4892
  const baseUrl = (process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, "")).replace(/\/$/, "");
@@ -4869,7 +4905,7 @@ export function registerDocTools(server, gql, defaults) {
4869
4905
  };
4870
4906
  server.registerTool("get_orphan_docs", {
4871
4907
  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).",
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).",
4873
4909
  inputSchema: { workspaceId: z.string().optional() },
4874
4910
  }, getOrphanDocsHandler);
4875
4911
  const listChildrenHandler = async (parsed) => {
@@ -4882,11 +4918,15 @@ export function registerDocTools(server, gql, defaults) {
4882
4918
  try {
4883
4919
  await joinWorkspace(socket, workspaceId);
4884
4920
  const titleById = new Map();
4921
+ const workspacePageIds = new Set();
4922
+ let hasWorkspaceMetadata = false;
4885
4923
  const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
4886
4924
  if (wsSnap.missing) {
4925
+ hasWorkspaceMetadata = true;
4887
4926
  const wsDoc = new Y.Doc();
4888
4927
  Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
4889
4928
  for (const page of getWorkspacePageEntries(wsDoc.getMap("meta"))) {
4929
+ workspacePageIds.add(page.id);
4890
4930
  if (page.title)
4891
4931
  titleById.set(page.id, page.title);
4892
4932
  }
@@ -4898,16 +4938,15 @@ export function registerDocTools(server, gql, defaults) {
4898
4938
  Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4899
4939
  const blocks = doc.getMap("blocks");
4900
4940
  const children = [];
4901
- for (const [, raw] of blocks) {
4902
- if (!(raw instanceof Y.Map))
4941
+ const seen = new Set();
4942
+ for (const pageId of collectLinkedChildIds(blocks)) {
4943
+ if (hasWorkspaceMetadata && !workspacePageIds.has(pageId))
4903
4944
  continue;
4904
- if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
4945
+ if (seen.has(pageId))
4905
4946
  continue;
4906
- const pageId = raw.get("prop:pageId");
4907
- if (typeof pageId === "string" && pageId) {
4908
- children.push({ docId: pageId, title: titleById.get(pageId) ?? null,
4909
- url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}` });
4910
- }
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}` });
4911
4950
  }
4912
4951
  return text({ docId: parsed.docId, count: children.length, children });
4913
4952
  }
@@ -4917,7 +4956,7 @@ export function registerDocTools(server, gql, defaults) {
4917
4956
  };
4918
4957
  server.registerTool("list_children", {
4919
4958
  title: "List Document Children",
4920
- 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.",
4921
4960
  inputSchema: {
4922
4961
  workspaceId: z.string().optional(),
4923
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,95 @@
1
+ import * as Y from "yjs";
2
+ import { loadDoc, pushDocUpdate } from "../ws.js";
3
+ /** Build the sub-document guid that holds a workspace's explorer icons. */
4
+ export function explorerIconDocId(workspaceId) {
5
+ return `db$${workspaceId}$explorerIcon`;
6
+ }
7
+ /** Build the per-entity map key for a document. */
8
+ export function docIconKey(docId) {
9
+ return `doc:${docId}`;
10
+ }
11
+ /** Build the per-entity map key for an organize folder. */
12
+ export function folderIconKey(folderId) {
13
+ return `folder:${folderId}`;
14
+ }
15
+ /**
16
+ * Coerce user input into the stored icon shape (or null to clear).
17
+ *
18
+ * - A bare string is treated as an emoji shorthand → `{ type: "emoji", unicode }`.
19
+ * - A `{ type: "emoji", unicode }` or `{ type: "icon", name }` object is passed
20
+ * through after validation. Named-icon `name` values are not validated against
21
+ * AFFiNE's fixed icon set — they are written as-is.
22
+ * - `null` clears the icon.
23
+ */
24
+ export function normalizeIconInput(input) {
25
+ if (input === null)
26
+ return null;
27
+ if (typeof input === "string") {
28
+ const unicode = input.trim();
29
+ if (!unicode)
30
+ throw new Error("icon emoji string must not be empty.");
31
+ return { type: "emoji", unicode };
32
+ }
33
+ if (input.type === "emoji") {
34
+ const unicode = input.unicode?.trim();
35
+ if (!unicode)
36
+ throw new Error("emoji icon requires a non-empty `unicode`.");
37
+ return { type: "emoji", unicode };
38
+ }
39
+ if (input.type === "icon") {
40
+ const name = input.name?.trim();
41
+ if (!name)
42
+ throw new Error("named icon requires a non-empty `name`.");
43
+ return { type: "icon", name };
44
+ }
45
+ throw new Error(`Unsupported icon type: ${JSON.stringify(input.type)}.`);
46
+ }
47
+ async function loadExplorerIconDoc(socket, workspaceId) {
48
+ const snap = await loadDoc(socket, workspaceId, explorerIconDocId(workspaceId));
49
+ const doc = new Y.Doc();
50
+ if (snap.missing)
51
+ Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
52
+ return doc;
53
+ }
54
+ function readIcon(doc, key) {
55
+ const map = doc.share.has(key) ? doc.getMap(key) : null;
56
+ if (!map || !map.has("icon"))
57
+ return null;
58
+ const raw = map.get("icon");
59
+ if (raw && typeof raw.toJSON === "function") {
60
+ return raw.toJSON();
61
+ }
62
+ return raw ?? null;
63
+ }
64
+ /**
65
+ * Set or clear the sidebar icon for a single explorer entity.
66
+ *
67
+ * Loads the workspace's `explorerIcon` sub-doc, mutates only the target entry,
68
+ * and pushes the minimal delta. Passing `icon = null` removes the `icon` field
69
+ * while preserving the entry's `id`.
70
+ */
71
+ export async function setExplorerIcon(socket, workspaceId, key, icon) {
72
+ const doc = await loadExplorerIconDoc(socket, workspaceId);
73
+ const prevSV = Y.encodeStateVector(doc);
74
+ const map = doc.getMap(key);
75
+ if (!map.has("id"))
76
+ map.set("id", key);
77
+ if (icon === null) {
78
+ if (map.has("icon"))
79
+ map.delete("icon");
80
+ }
81
+ else {
82
+ map.set("icon", icon);
83
+ }
84
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
85
+ await pushDocUpdate(socket, workspaceId, explorerIconDocId(workspaceId), Buffer.from(delta).toString("base64"));
86
+ return { key, icon };
87
+ }
88
+ /**
89
+ * Read the current sidebar icon for a single explorer entity.
90
+ * Returns `null` when no icon is set (or the entry does not exist).
91
+ */
92
+ export async function getExplorerIcon(socket, workspaceId, key) {
93
+ const doc = await loadExplorerIconDoc(socket, workspaceId);
94
+ return { key, icon: readIcon(doc, key) };
95
+ }
@@ -39,6 +39,8 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
39
39
  | `create_folder` | Create a root or nested folder | Experimental |
40
40
  | `create_workspace_blueprint` | Create a simple workspace folder blueprint | Good for structured onboarding setups |
41
41
  | `rename_folder` | Rename a folder | Experimental |
42
+ | `update_folder_icon` | Set or clear a folder's sidebar icon (emoji or named icon) | Experimental |
43
+ | `get_folder_icon` | Read a folder's current sidebar icon | Experimental |
42
44
  | `delete_folder` | Delete a folder recursively | Experimental and destructive |
43
45
  | `move_organize_node` | Move a folder or link node | Experimental |
44
46
  | `add_organize_link` | Add a doc, tag, or collection link under a folder | Experimental |
@@ -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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "affine-mcp-server",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Model Context Protocol server for AFFiNE - enables AI assistants to interact with AFFiNE workspaces, documents, and collaboration features.",
@@ -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",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0",
2
+ "version": "2.3.0",
3
3
  "tools": [
4
4
  "add_database_column",
5
5
  "add_database_row",
@@ -43,7 +43,9 @@
43
43
  "get_capabilities",
44
44
  "get_collection",
45
45
  "get_doc",
46
+ "get_doc_icon",
46
47
  "get_edgeless_canvas",
48
+ "get_folder_icon",
47
49
  "get_orphan_docs",
48
50
  "get_workspace",
49
51
  "inspect_template_structure",
@@ -83,8 +85,10 @@
83
85
  "update_collection_rules",
84
86
  "update_comment",
85
87
  "update_database_row",
88
+ "update_doc_icon",
86
89
  "update_doc_title",
87
90
  "update_edgeless_block",
91
+ "update_folder_icon",
88
92
  "update_frame_children",
89
93
  "update_profile",
90
94
  "update_settings",