affine-mcp-server 2.3.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.3.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 94 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.3.0: Added document and folder sidebar icon tools, plus richer hierarchy detection for inline LinkedPage references and synced-doc embeds.
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 |
@@ -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",
@@ -129,6 +130,7 @@ const TOOL_GROUPS = {
129
130
  delete_folder: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
130
131
  delete_organize_link: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
131
132
  delete_surface_element: ["docs", "docs.edgeless", "docs.surface", "docs.write", "destructive", "write"],
133
+ delete_tag: ["docs", "docs.tags", "docs.write", "destructive", "write"],
132
134
  delete_workspace: ["workspaces", "workspaces.write", "admin", "destructive", "write"],
133
135
  export_doc_markdown: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
134
136
  export_with_fidelity_report: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
@@ -463,6 +463,31 @@ export function registerDocTools(server, gql, defaults) {
463
463
  optionsArray.push([optionMap]);
464
464
  return { option, created: true };
465
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
+ }
466
491
  function collectMatchingTagIndexes(tags, requestedTag, option, ignoreCase) {
467
492
  const normalizedRequested = ignoreCase ? requestedTag.toLocaleLowerCase() : requestedTag;
468
493
  const normalizedOptionId = option
@@ -551,12 +576,19 @@ export function registerDocTools(server, gql, defaults) {
551
576
  const title = value.get("title");
552
577
  const createDate = value.get("createDate");
553
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);
554
585
  entries.push({
555
586
  index,
556
587
  id,
557
588
  title: typeof title === "string" ? title : null,
558
589
  createDate: typeof createDate === "number" ? createDate : null,
559
590
  updatedDate: typeof updatedDate === "number" ? updatedDate : null,
591
+ inTrash,
560
592
  entry: value,
561
593
  tagsArray: getTagArray(value),
562
594
  });
@@ -3177,6 +3209,7 @@ export function registerDocTools(server, gql, defaults) {
3177
3209
  const docs = data.workspace.docs;
3178
3210
  const tagsByDocId = new Map();
3179
3211
  const titlesByDocId = new Map();
3212
+ const inTrashByDocId = new Map();
3180
3213
  let workspacePageCount = null;
3181
3214
  let workspacePageIds = null;
3182
3215
  const deletedDocIds = new Set();
@@ -3201,6 +3234,7 @@ export function registerDocTools(server, gql, defaults) {
3201
3234
  }
3202
3235
  const tagEntries = getStringArray(page.tagsArray);
3203
3236
  tagsByDocId.set(page.id, resolveTagLabels(tagEntries, byId));
3237
+ inTrashByDocId.set(page.id, page.inTrash);
3204
3238
  }
3205
3239
  }
3206
3240
  const graphEdges = Array.isArray(docs?.edges) ? docs.edges : [];
@@ -3239,6 +3273,7 @@ export function registerDocTools(server, gql, defaults) {
3239
3273
  ...node,
3240
3274
  title: titlesByDocId.get(node.id) || node.title,
3241
3275
  tags: tagsByDocId.get(node.id) || [],
3276
+ inTrash: inTrashByDocId.get(node.id) ?? false,
3242
3277
  },
3243
3278
  };
3244
3279
  })
@@ -3272,7 +3307,7 @@ export function registerDocTools(server, gql, defaults) {
3272
3307
  };
3273
3308
  server.registerTool("list_docs", {
3274
3309
  title: "List Documents",
3275
- description: "List documents in a workspace (GraphQL).",
3310
+ description: "List documents in a workspace (GraphQL). Each doc includes an inTrash flag.",
3276
3311
  inputSchema: {
3277
3312
  workspaceId: z.string().describe("Workspace ID (optional if default set).").optional(),
3278
3313
  first: z.number().optional(),
@@ -3401,6 +3436,7 @@ export function registerDocTools(server, gql, defaults) {
3401
3436
  updatedAt: updatedTimestamp > 0 ? new Date(updatedTimestamp).toISOString() : null,
3402
3437
  updatedTimestamp,
3403
3438
  url: `${baseUrl}/workspace/${workspaceId}/${page.id}`,
3439
+ inTrash: page.inTrash,
3404
3440
  rank,
3405
3441
  };
3406
3442
  })
@@ -3429,6 +3465,7 @@ export function registerDocTools(server, gql, defaults) {
3429
3465
  tags: entry.tags,
3430
3466
  updatedAt: entry.updatedAt,
3431
3467
  url: entry.url,
3468
+ inTrash: entry.inTrash,
3432
3469
  }));
3433
3470
  return text({
3434
3471
  query: parsed.query,
@@ -3446,7 +3483,7 @@ export function registerDocTools(server, gql, defaults) {
3446
3483
  };
3447
3484
  server.registerTool("search_docs", {
3448
3485
  title: "Search Documents by Title",
3449
- 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.",
3450
3487
  inputSchema: {
3451
3488
  workspaceId: z.string().optional().describe("Workspace ID (optional if default set)."),
3452
3489
  query: z.string().describe("Search query — matched case-insensitively against doc titles."),
@@ -3510,6 +3547,7 @@ export function registerDocTools(server, gql, defaults) {
3510
3547
  title: pageTitle,
3511
3548
  createdAt: page.createDate ? new Date(page.createDate).toISOString() : null,
3512
3549
  updatedAt: updatedTimestamp ? new Date(updatedTimestamp).toISOString() : null,
3550
+ inTrash: page.inTrash,
3513
3551
  });
3514
3552
  }
3515
3553
  return text({
@@ -3531,7 +3569,7 @@ export function registerDocTools(server, gql, defaults) {
3531
3569
  "Reads workspace metadata — fast, no per-doc fetch. " +
3532
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). " +
3533
3571
  "Prefer this over `search_docs` when you know the exact title and want every match. " +
3534
- "Returns: { query, caseInsensitive, matches: [{ id, title, createdAt, updatedAt }], workspaceDocCount, truncated }.",
3572
+ "Returns: { query, caseInsensitive, matches: [{ id, title, createdAt, updatedAt, inTrash }], workspaceDocCount, truncated }.",
3535
3573
  inputSchema: {
3536
3574
  workspaceId: z.string().optional().describe("Workspace ID (optional if AFFINE_WORKSPACE_ID is set)."),
3537
3575
  title: z.string().min(1).describe("The exact title to match."),
@@ -3578,6 +3616,7 @@ export function registerDocTools(server, gql, defaults) {
3578
3616
  updatedDate: page.updatedDate,
3579
3617
  tags,
3580
3618
  rawTags,
3619
+ inTrash: page.inTrash,
3581
3620
  };
3582
3621
  })
3583
3622
  .filter((page) => hasTag(page.tags, tag, ignoreCase) || hasTag(page.rawTags, tag, ignoreCase))
@@ -3596,7 +3635,7 @@ export function registerDocTools(server, gql, defaults) {
3596
3635
  };
3597
3636
  server.registerTool("list_docs_by_tag", {
3598
3637
  title: "List Documents By Tag",
3599
- description: "List documents that contain the requested tag.",
3638
+ description: "List documents that contain the requested tag. Each doc includes an inTrash flag.",
3600
3639
  inputSchema: {
3601
3640
  workspaceId: WorkspaceId.optional(),
3602
3641
  tag: z.string().min(1).describe("Tag name"),
@@ -3790,6 +3829,121 @@ export function registerDocTools(server, gql, defaults) {
3790
3829
  tag: z.string().min(1).describe("Tag name"),
3791
3830
  },
3792
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);
3793
3947
  const getDocHandler = async (parsed) => {
3794
3948
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
3795
3949
  if (!workspaceId) {
@@ -4820,6 +4974,7 @@ export function registerDocTools(server, gql, defaults) {
4820
4974
  Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
4821
4975
  const pages = getWorkspacePageEntries(wsDoc.getMap("meta"));
4822
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]));
4823
4978
  const childrenOf = new Map();
4824
4979
  const allChildren = new Set();
4825
4980
  for (const page of pages) {
@@ -4844,6 +4999,7 @@ export function registerDocTools(server, gql, defaults) {
4844
4999
  const buildNode = (id, depth) => ({
4845
5000
  docId: id, title: titleById.get(id) ?? "Untitled",
4846
5001
  url: `${baseUrl}/workspace/${workspaceId}/${id}`,
5002
+ inTrash: trashById.get(id) ?? false,
4847
5003
  children: depth < maxDepth ? (childrenOf.get(id) ?? []).map(cid => buildNode(cid, depth + 1)) : [],
4848
5004
  });
4849
5005
  return text({ workspaceId, totalDocs: pages.length, rootCount: roots.length, tree: roots.map(id => buildNode(id, 0)) });
@@ -4854,7 +5010,7 @@ export function registerDocTools(server, gql, defaults) {
4854
5010
  };
4855
5011
  server.registerTool("list_workspace_tree", {
4856
5012
  title: "List Workspace Tree",
4857
- 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.",
4858
5014
  inputSchema: {
4859
5015
  workspaceId: z.string().optional(),
4860
5016
  depth: z.number().optional().describe("Max nesting depth to return (default: 3)."),
@@ -4896,6 +5052,7 @@ export function registerDocTools(server, gql, defaults) {
4896
5052
  docId: p.id,
4897
5053
  title: titleById.get(p.id) ?? "Untitled",
4898
5054
  url: `${baseUrl}/workspace/${workspaceId}/${p.id}`,
5055
+ inTrash: p.inTrash,
4899
5056
  }));
4900
5057
  return text({ count: orphans.length, orphans });
4901
5058
  }
@@ -4905,7 +5062,7 @@ export function registerDocTools(server, gql, defaults) {
4905
5062
  };
4906
5063
  server.registerTool("get_orphan_docs", {
4907
5064
  title: "Get Orphan Documents",
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).",
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.",
4909
5066
  inputSchema: { workspaceId: z.string().optional() },
4910
5067
  }, getOrphanDocsHandler);
4911
5068
  const listChildrenHandler = async (parsed) => {
@@ -4918,6 +5075,7 @@ export function registerDocTools(server, gql, defaults) {
4918
5075
  try {
4919
5076
  await joinWorkspace(socket, workspaceId);
4920
5077
  const titleById = new Map();
5078
+ const trashById = new Map();
4921
5079
  const workspacePageIds = new Set();
4922
5080
  let hasWorkspaceMetadata = false;
4923
5081
  const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
@@ -4927,6 +5085,7 @@ export function registerDocTools(server, gql, defaults) {
4927
5085
  Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
4928
5086
  for (const page of getWorkspacePageEntries(wsDoc.getMap("meta"))) {
4929
5087
  workspacePageIds.add(page.id);
5088
+ trashById.set(page.id, page.inTrash);
4930
5089
  if (page.title)
4931
5090
  titleById.set(page.id, page.title);
4932
5091
  }
@@ -4946,7 +5105,8 @@ export function registerDocTools(server, gql, defaults) {
4946
5105
  continue;
4947
5106
  seen.add(pageId);
4948
5107
  children.push({ docId: pageId, title: titleById.get(pageId) ?? null,
4949
- url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}` });
5108
+ url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}`,
5109
+ inTrash: trashById.get(pageId) ?? false });
4950
5110
  }
4951
5111
  return text({ docId: parsed.docId, count: children.length, children });
4952
5112
  }
@@ -4956,7 +5116,7 @@ export function registerDocTools(server, gql, defaults) {
4956
5116
  };
4957
5117
  server.registerTool("list_children", {
4958
5118
  title: "List Document Children",
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.",
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.",
4960
5120
  inputSchema: {
4961
5121
  workspaceId: z.string().optional(),
4962
5122
  docId: z.string().describe("The parent doc whose children to list."),
@@ -100,6 +100,7 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
100
100
  | `create_tag` | Create a reusable workspace-level tag | |
101
101
  | `add_tag_to_doc` | Attach a tag to a document | |
102
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 |
103
104
 
104
105
  ### Custom properties
105
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "affine-mcp-server",
3
- "version": "2.3.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.",
@@ -68,6 +68,7 @@
68
68
  "test:create-doc-folder-placement": "node tests/test-create-doc-folder-placement.mjs",
69
69
  "test:supporting-tools": "node tests/test-supporting-tools.mjs",
70
70
  "test:tag-visibility": "node tests/test-tag-visibility.mjs",
71
+ "test:tag-deletion": "node tests/test-tag-deletion.mjs",
71
72
  "test:markdown-roundtrip": "node tests/test-markdown-roundtrip.mjs",
72
73
  "test:markdown-rich-text-import": "node tests/test-markdown-rich-text-import.mjs",
73
74
  "test:playwright": "npx playwright test --config tests/playwright/playwright.config.ts",
@@ -99,7 +100,7 @@
99
100
  "markdown-it": "^14.1.0",
100
101
  "node-fetch": "^3.3.2",
101
102
  "socket.io-client": "^4.8.1",
102
- "undici": "^8.0.2",
103
+ "undici": "^6.27.0",
103
104
  "yjs": "^13.6.27",
104
105
  "zod": "^3.23.8"
105
106
  },
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2.3.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",