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 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.4.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 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.2.0: Added document custom-property tools, `read_doc` LinkedPage reference IDs, and table ordering fixes.
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") {
@@ -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([
@@ -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, and direct URL for each match.",
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 [, 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)) {
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 [, 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);
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
- for (const [, raw] of blocks) {
4902
- if (!(raw instanceof Y.Map))
5100
+ const seen = new Set();
5101
+ for (const pageId of collectLinkedChildIds(blocks)) {
5102
+ if (hasWorkspaceMetadata && !workspacePageIds.has(pageId))
4903
5103
  continue;
4904
- if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
5104
+ if (seen.has(pageId))
4905
5105
  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
- }
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 URL for each child.",
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
+ }
@@ -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.2.0",
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": "^8.0.2",
103
+ "undici": "^6.27.0",
102
104
  "yjs": "^13.6.27",
103
105
  "zod": "^3.23.8"
104
106
  },
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.2.0",
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",