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 +3 -3
- package/dist/index.js +2 -0
- package/dist/toolSurface.js +12 -0
- package/dist/tools/docs.js +64 -25
- package/dist/tools/icons.js +125 -0
- package/dist/util/explorerIcon.js +95 -0
- package/docs/tool-reference.md +4 -0
- package/package.json +2 -1
- package/tool-manifest.json +5 -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 |
|
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
|
@@ -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([
|
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");
|
|
@@ -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
|
|
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)) {
|
|
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
|
|
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);
|
|
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
|
-
|
|
4902
|
-
|
|
4941
|
+
const seen = new Set();
|
|
4942
|
+
for (const pageId of collectLinkedChildIds(blocks)) {
|
|
4943
|
+
if (hasWorkspaceMetadata && !workspacePageIds.has(pageId))
|
|
4903
4944
|
continue;
|
|
4904
|
-
if (
|
|
4945
|
+
if (seen.has(pageId))
|
|
4905
4946
|
continue;
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
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
|
+
}
|
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 |
|
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.",
|
|
@@ -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",
|
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",
|
|
@@ -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",
|