affine-mcp-server 1.7.0 → 1.7.2

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 that integrates with AFFiNE (self‑hosted or cloud). It exposes AFFiNE workspaces and documents to AI assistants over stdio (default) or HTTP (`/mcp`).
4
4
 
5
- [![Version](https://img.shields.io/badge/version-1.7.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-1.7.2-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)
@@ -19,7 +19,7 @@ A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted
19
19
  - Tools: 43 focused tools with WebSocket-based document editing
20
20
  - Status: Active
21
21
 
22
- > New in v1.7.0: Added remote HTTP MCP support (`/mcp`) with token/CORS controls, while retaining legacy SSE compatibility (`/sse`, `/messages`) for older clients.
22
+ > New in v1.7.2: Fixed tag visibility parity in AFFiNE Web/App for MCP-created tags and hardened Docker E2E startup reliability with retry/diagnostics.
23
23
 
24
24
  ## Features
25
25
 
@@ -409,6 +409,18 @@ Workspace visibility
409
409
 
410
410
  ## Version History
411
411
 
412
+ ### 1.7.2 (2026‑03‑04)
413
+ - Fixed MCP tag persistence to use AFFiNE canonical tag option IDs so tags are visible in Web/App UI
414
+ - Added backward-compatible tag normalization for legacy string tag entries
415
+ - Added tag visibility regression coverage (`tests/test-tag-visibility.mjs`, `tests/playwright/verify-tag-visibility.pw.ts`)
416
+ - Hardened E2E credential bootstrap with configurable health retries, retry attempts, and Docker diagnostics on failure
417
+ - Verified CI gates (`validate`, `e2e`) for PR #46 and local `npm run ci`
418
+
419
+ ### 1.7.1 (2026‑03‑03)
420
+ - Fixed MCP-created document structure parity with AFFiNE UI (`sys:parent` handling)
421
+ - Fixed callout text rendering parity in AFFiNE UI for MCP-created blocks
422
+ - Added regression assertions for visibility-sensitive document creation paths
423
+
412
424
  ### 1.7.0 (2026‑02‑27)
413
425
  - Added Streamable HTTP MCP support on `/mcp` for remote hosting while keeping legacy SSE compatibility paths (`/sse`, `/messages`)
414
426
  - Added HTTP deployment controls: `AFFINE_MCP_HTTP_HOST`, `AFFINE_MCP_HTTP_TOKEN`, `AFFINE_MCP_HTTP_ALLOWED_ORIGINS`, `AFFINE_MCP_HTTP_ALLOW_ALL_ORIGINS`
@@ -118,6 +118,11 @@ export function registerDocTools(server, gql, defaults) {
118
118
  }
119
119
  return normalized;
120
120
  }
121
+ const TAG_OPTION_COLORS = [
122
+ "var(--affine-tag-blue)", "var(--affine-tag-green)", "var(--affine-tag-red)",
123
+ "var(--affine-tag-orange)", "var(--affine-tag-purple)", "var(--affine-tag-yellow)",
124
+ "var(--affine-tag-teal)", "var(--affine-tag-pink)", "var(--affine-tag-gray)",
125
+ ];
121
126
  function getStringArray(value) {
122
127
  if (!(value instanceof Y.Array)) {
123
128
  return [];
@@ -146,23 +151,235 @@ export function registerDocTools(server, gql, defaults) {
146
151
  target.set(key, next);
147
152
  return next;
148
153
  }
149
- function hasTag(tagValues, tag, ignoreCase) {
150
- const normalizedTag = ignoreCase ? tag.toLocaleLowerCase() : tag;
151
- return tagValues.some((entry) => (ignoreCase ? entry.toLocaleLowerCase() : entry) === normalizedTag);
154
+ function getYMap(target, key) {
155
+ const value = target.get(key);
156
+ if (!(value instanceof Y.Map)) {
157
+ return null;
158
+ }
159
+ return value;
152
160
  }
153
- function findTagIndex(tags, tag, ignoreCase) {
154
- const normalizedTag = ignoreCase ? tag.toLocaleLowerCase() : tag;
155
- let index = -1;
156
- tags.forEach((entry, i) => {
157
- if (index >= 0 || typeof entry !== "string") {
161
+ function ensureYMap(target, key) {
162
+ const current = getYMap(target, key);
163
+ if (current) {
164
+ return current;
165
+ }
166
+ const next = new Y.Map();
167
+ target.set(key, next);
168
+ return next;
169
+ }
170
+ function getWorkspaceTagOptionsArray(meta) {
171
+ const properties = getYMap(meta, "properties");
172
+ if (!properties) {
173
+ return null;
174
+ }
175
+ const tags = getYMap(properties, "tags");
176
+ if (!tags) {
177
+ return null;
178
+ }
179
+ const options = tags.get("options");
180
+ if (!(options instanceof Y.Array)) {
181
+ return null;
182
+ }
183
+ return options;
184
+ }
185
+ function ensureWorkspaceTagOptionsArray(meta) {
186
+ const properties = ensureYMap(meta, "properties");
187
+ const tags = ensureYMap(properties, "tags");
188
+ const existing = tags.get("options");
189
+ if (existing instanceof Y.Array) {
190
+ return existing;
191
+ }
192
+ const next = new Y.Array();
193
+ tags.set("options", next);
194
+ return next;
195
+ }
196
+ function asNumberOrNull(value) {
197
+ return typeof value === "number" ? value : null;
198
+ }
199
+ function parseWorkspaceTagOption(raw) {
200
+ let id;
201
+ let value;
202
+ let color;
203
+ let createDate;
204
+ let updateDate;
205
+ if (raw instanceof Y.Map) {
206
+ id = raw.get("id");
207
+ value = raw.get("value");
208
+ color = raw.get("color");
209
+ createDate = raw.get("createDate");
210
+ updateDate = raw.get("updateDate");
211
+ }
212
+ else if (raw && typeof raw === "object") {
213
+ id = raw.id;
214
+ value = raw.value;
215
+ color = raw.color;
216
+ createDate = raw.createDate;
217
+ updateDate = raw.updateDate;
218
+ }
219
+ else {
220
+ return null;
221
+ }
222
+ if (typeof id !== "string" || id.trim().length === 0) {
223
+ return null;
224
+ }
225
+ if (typeof value !== "string" || value.trim().length === 0) {
226
+ return null;
227
+ }
228
+ return {
229
+ id,
230
+ value,
231
+ color: typeof color === "string" && color.trim().length > 0 ? color : TAG_OPTION_COLORS[0],
232
+ createDate: asNumberOrNull(createDate),
233
+ updateDate: asNumberOrNull(updateDate),
234
+ };
235
+ }
236
+ function getWorkspaceTagOptions(meta) {
237
+ const options = getWorkspaceTagOptionsArray(meta);
238
+ if (!options) {
239
+ return [];
240
+ }
241
+ const parsed = [];
242
+ options.forEach((raw) => {
243
+ const option = parseWorkspaceTagOption(raw);
244
+ if (option) {
245
+ parsed.push(option);
246
+ }
247
+ });
248
+ return parsed;
249
+ }
250
+ function getWorkspaceTagOptionMaps(meta) {
251
+ const options = getWorkspaceTagOptions(meta);
252
+ const byId = new Map();
253
+ const byValueLower = new Map();
254
+ for (const option of options) {
255
+ if (!byId.has(option.id)) {
256
+ byId.set(option.id, option);
257
+ }
258
+ const key = option.value.toLocaleLowerCase();
259
+ if (!byValueLower.has(key)) {
260
+ byValueLower.set(key, option);
261
+ }
262
+ }
263
+ return { options, byId, byValueLower };
264
+ }
265
+ function resolveTagLabels(tagEntries, byId) {
266
+ const deduped = new Set();
267
+ const resolved = [];
268
+ for (const entry of tagEntries) {
269
+ const raw = entry.trim();
270
+ if (!raw) {
271
+ continue;
272
+ }
273
+ const option = byId.get(raw);
274
+ const label = (option ? option.value : raw).trim();
275
+ if (!label) {
276
+ continue;
277
+ }
278
+ const dedupeKey = label.toLocaleLowerCase();
279
+ if (deduped.has(dedupeKey)) {
280
+ continue;
281
+ }
282
+ deduped.add(dedupeKey);
283
+ resolved.push(label);
284
+ }
285
+ return resolved;
286
+ }
287
+ function ensureWorkspaceTagOption(meta, tag) {
288
+ const normalizedTag = normalizeTag(tag);
289
+ const maps = getWorkspaceTagOptionMaps(meta);
290
+ const existing = maps.byValueLower.get(normalizedTag.toLocaleLowerCase());
291
+ if (existing) {
292
+ return { option: existing, created: false };
293
+ }
294
+ const optionsArray = ensureWorkspaceTagOptionsArray(meta);
295
+ const color = TAG_OPTION_COLORS[maps.options.length % TAG_OPTION_COLORS.length];
296
+ const now = Date.now();
297
+ const option = {
298
+ id: generateId(),
299
+ value: normalizedTag,
300
+ color,
301
+ createDate: now,
302
+ updateDate: now,
303
+ };
304
+ const optionMap = new Y.Map();
305
+ optionMap.set("id", option.id);
306
+ optionMap.set("value", option.value);
307
+ optionMap.set("color", option.color);
308
+ optionMap.set("createDate", now);
309
+ optionMap.set("updateDate", now);
310
+ optionsArray.push([optionMap]);
311
+ return { option, created: true };
312
+ }
313
+ function collectMatchingTagIndexes(tags, requestedTag, option, ignoreCase) {
314
+ const normalizedRequested = ignoreCase ? requestedTag.toLocaleLowerCase() : requestedTag;
315
+ const normalizedOptionId = option
316
+ ? (ignoreCase ? option.id.toLocaleLowerCase() : option.id)
317
+ : null;
318
+ const normalizedOptionValue = option
319
+ ? (ignoreCase ? option.value.toLocaleLowerCase() : option.value)
320
+ : null;
321
+ const indexes = [];
322
+ tags.forEach((entry, index) => {
323
+ if (typeof entry !== "string") {
158
324
  return;
159
325
  }
160
326
  const current = ignoreCase ? entry.toLocaleLowerCase() : entry;
161
- if (current === normalizedTag) {
162
- index = i;
327
+ if (current === normalizedRequested ||
328
+ (normalizedOptionId && current === normalizedOptionId) ||
329
+ (normalizedOptionValue && current === normalizedOptionValue)) {
330
+ indexes.push(index);
163
331
  }
164
332
  });
165
- return index;
333
+ return indexes;
334
+ }
335
+ function deleteArrayIndexes(arr, indexes) {
336
+ if (indexes.length === 0) {
337
+ return false;
338
+ }
339
+ const sorted = [...indexes].sort((a, b) => b - a);
340
+ for (const index of sorted) {
341
+ arr.delete(index, 1);
342
+ }
343
+ return true;
344
+ }
345
+ function syncTagArrayToOption(tags, requestedTag, option) {
346
+ const optionId = option.id.toLocaleLowerCase();
347
+ const optionValue = option.value.toLocaleLowerCase();
348
+ const requested = requestedTag.toLocaleLowerCase();
349
+ let existed = false;
350
+ let hasCanonicalId = false;
351
+ const removeIndexes = [];
352
+ tags.forEach((entry, index) => {
353
+ if (typeof entry !== "string") {
354
+ return;
355
+ }
356
+ const current = entry.toLocaleLowerCase();
357
+ const matched = current === optionId || current === optionValue || current === requested;
358
+ if (!matched) {
359
+ return;
360
+ }
361
+ existed = true;
362
+ if (current === optionId) {
363
+ if (hasCanonicalId) {
364
+ removeIndexes.push(index);
365
+ }
366
+ else {
367
+ hasCanonicalId = true;
368
+ }
369
+ return;
370
+ }
371
+ removeIndexes.push(index);
372
+ });
373
+ let changed = deleteArrayIndexes(tags, removeIndexes);
374
+ if (!hasCanonicalId) {
375
+ tags.push([option.id]);
376
+ changed = true;
377
+ }
378
+ return { existed, changed };
379
+ }
380
+ function hasTag(tagValues, tag, ignoreCase) {
381
+ const normalizedTag = ignoreCase ? tag.toLocaleLowerCase() : tag;
382
+ return tagValues.some((entry) => (ignoreCase ? entry.toLocaleLowerCase() : entry) === normalizedTag);
166
383
  }
167
384
  function getWorkspacePageEntries(meta) {
168
385
  const pages = meta.get("pages");
@@ -219,7 +436,7 @@ export function registerDocTools(server, gql, defaults) {
219
436
  const noteId = generateId();
220
437
  const note = new Y.Map();
221
438
  setSysFields(note, noteId, "affine:note");
222
- note.set("sys:parent", pageId);
439
+ note.set("sys:parent", null);
223
440
  note.set("sys:children", new Y.Array());
224
441
  note.set("prop:xywh", "[0,0,800,95]");
225
442
  note.set("prop:index", "a0");
@@ -251,7 +468,7 @@ export function registerDocTools(server, gql, defaults) {
251
468
  const surfaceId = generateId();
252
469
  const surface = new Y.Map();
253
470
  setSysFields(surface, surfaceId, "affine:surface");
254
- surface.set("sys:parent", pageId);
471
+ surface.set("sys:parent", null);
255
472
  surface.set("sys:children", new Y.Array());
256
473
  const elements = new Y.Map();
257
474
  elements.set("type", "$blocksuite:internal:native$");
@@ -601,6 +818,30 @@ export function registerDocTools(server, gql, defaults) {
601
818
  });
602
819
  return index;
603
820
  }
821
+ function findParentIdByChild(blocks, childId) {
822
+ for (const [id, value] of blocks) {
823
+ if (!(value instanceof Y.Map)) {
824
+ continue;
825
+ }
826
+ const childIds = childIdsFrom(value.get("sys:children"));
827
+ if (childIds.includes(childId)) {
828
+ return String(id);
829
+ }
830
+ }
831
+ return null;
832
+ }
833
+ function resolveBlockParentId(blocks, blockId) {
834
+ const block = findBlockById(blocks, blockId);
835
+ if (!block) {
836
+ return null;
837
+ }
838
+ const rawParentId = block.get("sys:parent");
839
+ if (typeof rawParentId === "string" && rawParentId.trim().length > 0) {
840
+ return rawParentId;
841
+ }
842
+ // AFFiNE UI commonly stores sys:parent as null and derives hierarchy from sys:children.
843
+ return findParentIdByChild(blocks, blockId);
844
+ }
604
845
  function resolveInsertContext(blocks, normalized) {
605
846
  const placement = normalized.placement;
606
847
  let parentId;
@@ -612,8 +853,8 @@ export function registerDocTools(server, gql, defaults) {
612
853
  const referenceBlock = findBlockById(blocks, referenceBlockId);
613
854
  if (!referenceBlock)
614
855
  throw new Error(`placement.afterBlockId '${referenceBlockId}' was not found.`);
615
- const refParentId = referenceBlock.get("sys:parent");
616
- if (typeof refParentId !== "string" || !refParentId) {
856
+ const refParentId = resolveBlockParentId(blocks, referenceBlockId);
857
+ if (!refParentId) {
617
858
  throw new Error(`Block '${referenceBlockId}' has no parent.`);
618
859
  }
619
860
  parentId = refParentId;
@@ -624,8 +865,8 @@ export function registerDocTools(server, gql, defaults) {
624
865
  const referenceBlock = findBlockById(blocks, referenceBlockId);
625
866
  if (!referenceBlock)
626
867
  throw new Error(`placement.beforeBlockId '${referenceBlockId}' was not found.`);
627
- const refParentId = referenceBlock.get("sys:parent");
628
- if (typeof refParentId !== "string" || !refParentId) {
868
+ const refParentId = resolveBlockParentId(blocks, referenceBlockId);
869
+ if (!refParentId) {
629
870
  throw new Error(`Block '${referenceBlockId}' has no parent.`);
630
871
  }
631
872
  parentId = refParentId;
@@ -688,16 +929,17 @@ export function registerDocTools(server, gql, defaults) {
688
929
  }
689
930
  return { parentId, parentBlock, children, insertIndex };
690
931
  }
691
- function createBlock(parentId, normalized) {
932
+ function createBlock(normalized) {
692
933
  const blockId = generateId();
693
934
  const block = new Y.Map();
694
935
  const content = normalized.text;
936
+ // Keep parity with AFFiNE UI-created docs: sys:parent stays null and hierarchy is represented by sys:children.
695
937
  switch (normalized.type) {
696
938
  case "paragraph":
697
939
  case "heading":
698
940
  case "quote": {
699
941
  setSysFields(block, blockId, "affine:paragraph");
700
- block.set("sys:parent", parentId);
942
+ block.set("sys:parent", null);
701
943
  block.set("sys:children", new Y.Array());
702
944
  const blockType = normalized.type === "heading"
703
945
  ? `h${normalized.headingLevel}`
@@ -710,7 +952,7 @@ export function registerDocTools(server, gql, defaults) {
710
952
  }
711
953
  case "list": {
712
954
  setSysFields(block, blockId, "affine:list");
713
- block.set("sys:parent", parentId);
955
+ block.set("sys:parent", null);
714
956
  block.set("sys:children", new Y.Array());
715
957
  block.set("prop:type", normalized.listStyle);
716
958
  block.set("prop:checked", normalized.listStyle === "todo" ? normalized.checked : false);
@@ -719,7 +961,7 @@ export function registerDocTools(server, gql, defaults) {
719
961
  }
720
962
  case "code": {
721
963
  setSysFields(block, blockId, "affine:code");
722
- block.set("sys:parent", parentId);
964
+ block.set("sys:parent", null);
723
965
  block.set("sys:children", new Y.Array());
724
966
  block.set("prop:language", normalized.language);
725
967
  if (normalized.caption) {
@@ -730,22 +972,35 @@ export function registerDocTools(server, gql, defaults) {
730
972
  }
731
973
  case "divider": {
732
974
  setSysFields(block, blockId, "affine:divider");
733
- block.set("sys:parent", parentId);
975
+ block.set("sys:parent", null);
734
976
  block.set("sys:children", new Y.Array());
735
977
  return { blockId, block, flavour: "affine:divider" };
736
978
  }
737
979
  case "callout": {
738
980
  setSysFields(block, blockId, "affine:callout");
739
- block.set("sys:parent", parentId);
740
- block.set("sys:children", new Y.Array());
981
+ block.set("sys:parent", null);
982
+ const calloutChildren = new Y.Array();
983
+ const textBlockId = generateId();
984
+ const textBlock = new Y.Map();
985
+ setSysFields(textBlock, textBlockId, "affine:paragraph");
986
+ textBlock.set("sys:parent", null);
987
+ textBlock.set("sys:children", new Y.Array());
988
+ textBlock.set("prop:type", "text");
989
+ textBlock.set("prop:text", makeText(content));
990
+ calloutChildren.push([textBlockId]);
991
+ block.set("sys:children", calloutChildren);
741
992
  block.set("prop:icon", { type: "emoji", unicode: "💡" });
742
993
  block.set("prop:backgroundColorName", "grey");
743
- block.set("prop:text", makeText(content));
744
- return { blockId, block, flavour: "affine:callout" };
994
+ return {
995
+ blockId,
996
+ block,
997
+ flavour: "affine:callout",
998
+ extraBlocks: [{ blockId: textBlockId, block: textBlock }],
999
+ };
745
1000
  }
746
1001
  case "latex": {
747
1002
  setSysFields(block, blockId, "affine:latex");
748
- block.set("sys:parent", parentId);
1003
+ block.set("sys:parent", null);
749
1004
  block.set("sys:children", new Y.Array());
750
1005
  block.set("prop:xywh", "[0,0,16,16]");
751
1006
  block.set("prop:index", "a0");
@@ -757,7 +1012,7 @@ export function registerDocTools(server, gql, defaults) {
757
1012
  }
758
1013
  case "table": {
759
1014
  setSysFields(block, blockId, "affine:table");
760
- block.set("sys:parent", parentId);
1015
+ block.set("sys:parent", null);
761
1016
  block.set("sys:children", new Y.Array());
762
1017
  const rows = {};
763
1018
  const columns = {};
@@ -792,7 +1047,7 @@ export function registerDocTools(server, gql, defaults) {
792
1047
  }
793
1048
  case "bookmark": {
794
1049
  setSysFields(block, blockId, "affine:bookmark");
795
- block.set("sys:parent", parentId);
1050
+ block.set("sys:parent", null);
796
1051
  block.set("sys:children", new Y.Array());
797
1052
  block.set("prop:style", normalized.bookmarkStyle);
798
1053
  block.set("prop:url", normalized.url);
@@ -810,7 +1065,7 @@ export function registerDocTools(server, gql, defaults) {
810
1065
  }
811
1066
  case "image": {
812
1067
  setSysFields(block, blockId, "affine:image");
813
- block.set("sys:parent", parentId);
1068
+ block.set("sys:parent", null);
814
1069
  block.set("sys:children", new Y.Array());
815
1070
  block.set("prop:caption", normalized.caption ?? "");
816
1071
  block.set("prop:sourceId", normalized.sourceId);
@@ -825,7 +1080,7 @@ export function registerDocTools(server, gql, defaults) {
825
1080
  }
826
1081
  case "attachment": {
827
1082
  setSysFields(block, blockId, "affine:attachment");
828
- block.set("sys:parent", parentId);
1083
+ block.set("sys:parent", null);
829
1084
  block.set("sys:children", new Y.Array());
830
1085
  block.set("prop:name", normalized.name);
831
1086
  block.set("prop:size", normalized.size);
@@ -843,7 +1098,7 @@ export function registerDocTools(server, gql, defaults) {
843
1098
  }
844
1099
  case "embed_youtube": {
845
1100
  setSysFields(block, blockId, "affine:embed-youtube");
846
- block.set("sys:parent", parentId);
1101
+ block.set("sys:parent", null);
847
1102
  block.set("sys:children", new Y.Array());
848
1103
  block.set("prop:index", "a0");
849
1104
  block.set("prop:xywh", "[0,0,0,0]");
@@ -863,7 +1118,7 @@ export function registerDocTools(server, gql, defaults) {
863
1118
  }
864
1119
  case "embed_github": {
865
1120
  setSysFields(block, blockId, "affine:embed-github");
866
- block.set("sys:parent", parentId);
1121
+ block.set("sys:parent", null);
867
1122
  block.set("sys:children", new Y.Array());
868
1123
  block.set("prop:index", "a0");
869
1124
  block.set("prop:xywh", "[0,0,0,0]");
@@ -887,7 +1142,7 @@ export function registerDocTools(server, gql, defaults) {
887
1142
  }
888
1143
  case "embed_figma": {
889
1144
  setSysFields(block, blockId, "affine:embed-figma");
890
- block.set("sys:parent", parentId);
1145
+ block.set("sys:parent", null);
891
1146
  block.set("sys:children", new Y.Array());
892
1147
  block.set("prop:index", "a0");
893
1148
  block.set("prop:xywh", "[0,0,0,0]");
@@ -902,7 +1157,7 @@ export function registerDocTools(server, gql, defaults) {
902
1157
  }
903
1158
  case "embed_loom": {
904
1159
  setSysFields(block, blockId, "affine:embed-loom");
905
- block.set("sys:parent", parentId);
1160
+ block.set("sys:parent", null);
906
1161
  block.set("sys:children", new Y.Array());
907
1162
  block.set("prop:index", "a0");
908
1163
  block.set("prop:xywh", "[0,0,0,0]");
@@ -919,7 +1174,7 @@ export function registerDocTools(server, gql, defaults) {
919
1174
  }
920
1175
  case "embed_html": {
921
1176
  setSysFields(block, blockId, "affine:embed-html");
922
- block.set("sys:parent", parentId);
1177
+ block.set("sys:parent", null);
923
1178
  block.set("sys:children", new Y.Array());
924
1179
  block.set("prop:index", "a0");
925
1180
  block.set("prop:xywh", "[0,0,0,0]");
@@ -933,7 +1188,7 @@ export function registerDocTools(server, gql, defaults) {
933
1188
  }
934
1189
  case "embed_linked_doc": {
935
1190
  setSysFields(block, blockId, "affine:embed-linked-doc");
936
- block.set("sys:parent", parentId);
1191
+ block.set("sys:parent", null);
937
1192
  block.set("sys:children", new Y.Array());
938
1193
  block.set("prop:index", "a0");
939
1194
  block.set("prop:xywh", "[0,0,0,0]");
@@ -949,7 +1204,7 @@ export function registerDocTools(server, gql, defaults) {
949
1204
  }
950
1205
  case "embed_synced_doc": {
951
1206
  setSysFields(block, blockId, "affine:embed-synced-doc");
952
- block.set("sys:parent", parentId);
1207
+ block.set("sys:parent", null);
953
1208
  block.set("sys:children", new Y.Array());
954
1209
  block.set("prop:index", "a0");
955
1210
  block.set("prop:xywh", "[0,0,800,100]");
@@ -966,7 +1221,7 @@ export function registerDocTools(server, gql, defaults) {
966
1221
  }
967
1222
  case "embed_iframe": {
968
1223
  setSysFields(block, blockId, "affine:embed-iframe");
969
- block.set("sys:parent", parentId);
1224
+ block.set("sys:parent", null);
970
1225
  block.set("sys:children", new Y.Array());
971
1226
  block.set("prop:index", "a0");
972
1227
  block.set("prop:xywh", "[0,0,0,0]");
@@ -983,7 +1238,7 @@ export function registerDocTools(server, gql, defaults) {
983
1238
  }
984
1239
  case "database": {
985
1240
  setSysFields(block, blockId, "affine:database");
986
- block.set("sys:parent", parentId);
1241
+ block.set("sys:parent", null);
987
1242
  block.set("sys:children", new Y.Array());
988
1243
  // Create a default table view so AFFiNE UI renders the database
989
1244
  const defaultView = new Y.Map();
@@ -1008,7 +1263,7 @@ export function registerDocTools(server, gql, defaults) {
1008
1263
  // AFFiNE 0.26.x currently crashes on raw affine:data-view render path.
1009
1264
  // Keep API compatibility for type="data_view" by mapping it to the stable database block.
1010
1265
  setSysFields(block, blockId, "affine:database");
1011
- block.set("sys:parent", parentId);
1266
+ block.set("sys:parent", null);
1012
1267
  block.set("sys:children", new Y.Array());
1013
1268
  const dvDefaultView = new Y.Map();
1014
1269
  dvDefaultView.set("id", generateId());
@@ -1030,7 +1285,7 @@ export function registerDocTools(server, gql, defaults) {
1030
1285
  }
1031
1286
  case "surface_ref": {
1032
1287
  setSysFields(block, blockId, "affine:surface-ref");
1033
- block.set("sys:parent", parentId);
1288
+ block.set("sys:parent", null);
1034
1289
  block.set("sys:children", new Y.Array());
1035
1290
  block.set("prop:reference", normalized.reference);
1036
1291
  block.set("prop:caption", normalized.caption ?? "");
@@ -1040,7 +1295,7 @@ export function registerDocTools(server, gql, defaults) {
1040
1295
  }
1041
1296
  case "frame": {
1042
1297
  setSysFields(block, blockId, "affine:frame");
1043
- block.set("sys:parent", parentId);
1298
+ block.set("sys:parent", null);
1044
1299
  block.set("sys:children", new Y.Array());
1045
1300
  block.set("prop:title", makeText(content || "Frame"));
1046
1301
  block.set("prop:background", normalized.background);
@@ -1053,7 +1308,7 @@ export function registerDocTools(server, gql, defaults) {
1053
1308
  }
1054
1309
  case "edgeless_text": {
1055
1310
  setSysFields(block, blockId, "affine:edgeless-text");
1056
- block.set("sys:parent", parentId);
1311
+ block.set("sys:parent", null);
1057
1312
  block.set("sys:children", new Y.Array());
1058
1313
  block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1059
1314
  block.set("prop:index", "a0");
@@ -1071,7 +1326,7 @@ export function registerDocTools(server, gql, defaults) {
1071
1326
  }
1072
1327
  case "note": {
1073
1328
  setSysFields(block, blockId, "affine:note");
1074
- block.set("sys:parent", parentId);
1329
+ block.set("sys:parent", null);
1075
1330
  block.set("sys:children", new Y.Array());
1076
1331
  block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1077
1332
  block.set("prop:background", normalized.background);
@@ -1110,8 +1365,13 @@ export function registerDocTools(server, gql, defaults) {
1110
1365
  const prevSV = Y.encodeStateVector(doc);
1111
1366
  const blocks = doc.getMap("blocks");
1112
1367
  const context = resolveInsertContext(blocks, normalized);
1113
- const { blockId, block, flavour, blockType } = createBlock(context.parentId, normalized);
1368
+ const { blockId, block, flavour, blockType, extraBlocks } = createBlock(normalized);
1114
1369
  blocks.set(blockId, block);
1370
+ if (Array.isArray(extraBlocks)) {
1371
+ for (const extra of extraBlocks) {
1372
+ blocks.set(extra.blockId, extra.block);
1373
+ }
1374
+ }
1115
1375
  if (context.insertIndex >= context.children.length) {
1116
1376
  context.children.push([blockId]);
1117
1377
  }
@@ -1330,9 +1590,9 @@ export function registerDocTools(server, gql, defaults) {
1330
1590
  }
1331
1591
  return tableData;
1332
1592
  }
1333
- function collectDocForMarkdown(doc) {
1593
+ function collectDocForMarkdown(doc, tagOptionsById = new Map()) {
1334
1594
  const meta = doc.getMap("meta");
1335
- const tags = getStringArray(getTagArray(meta));
1595
+ const tags = resolveTagLabels(getStringArray(getTagArray(meta)), tagOptionsById);
1336
1596
  const blocks = doc.getMap("blocks");
1337
1597
  const pageId = findBlockIdByFlavour(blocks, "affine:page");
1338
1598
  const noteId = findBlockIdByFlavour(blocks, "affine:note");
@@ -1444,8 +1704,13 @@ export function registerDocTools(server, gql, defaults) {
1444
1704
  try {
1445
1705
  const normalized = normalizeAppendBlockInput(appendInput);
1446
1706
  const context = resolveInsertContext(blocks, normalized);
1447
- const { blockId, block } = createBlock(context.parentId, normalized);
1707
+ const { blockId, block, extraBlocks } = createBlock(normalized);
1448
1708
  blocks.set(blockId, block);
1709
+ if (Array.isArray(extraBlocks)) {
1710
+ for (const extra of extraBlocks) {
1711
+ blocks.set(extra.blockId, extra.block);
1712
+ }
1713
+ }
1449
1714
  if (context.insertIndex >= context.children.length) {
1450
1715
  context.children.push([blockId]);
1451
1716
  }
@@ -1499,7 +1764,7 @@ export function registerDocTools(server, gql, defaults) {
1499
1764
  const surfaceId = generateId();
1500
1765
  const surface = new Y.Map();
1501
1766
  setSysFields(surface, surfaceId, "affine:surface");
1502
- surface.set("sys:parent", pageId);
1767
+ surface.set("sys:parent", null);
1503
1768
  surface.set("sys:children", new Y.Array());
1504
1769
  const elements = new Y.Map();
1505
1770
  elements.set("type", "$blocksuite:internal:native$");
@@ -1510,7 +1775,7 @@ export function registerDocTools(server, gql, defaults) {
1510
1775
  const noteId = generateId();
1511
1776
  const note = new Y.Map();
1512
1777
  setSysFields(note, noteId, "affine:note");
1513
- note.set("sys:parent", pageId);
1778
+ note.set("sys:parent", null);
1514
1779
  note.set("prop:displayMode", "both");
1515
1780
  note.set("prop:xywh", "[0,0,800,95]");
1516
1781
  note.set("prop:index", "a0");
@@ -1527,7 +1792,7 @@ export function registerDocTools(server, gql, defaults) {
1527
1792
  const paraId = generateId();
1528
1793
  const para = new Y.Map();
1529
1794
  setSysFields(para, paraId, "affine:paragraph");
1530
- para.set("sys:parent", noteId);
1795
+ para.set("sys:parent", null);
1531
1796
  para.set("sys:children", new Y.Array());
1532
1797
  para.set("prop:type", "text");
1533
1798
  const paragraphText = new Y.Text();
@@ -1592,8 +1857,10 @@ export function registerDocTools(server, gql, defaults) {
1592
1857
  Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
1593
1858
  const meta = wsDoc.getMap("meta");
1594
1859
  const pages = getWorkspacePageEntries(meta);
1860
+ const { byId } = getWorkspaceTagOptionMaps(meta);
1595
1861
  for (const page of pages) {
1596
- tagsByDocId.set(page.id, getStringArray(page.tagsArray));
1862
+ const tagEntries = getStringArray(page.tagsArray);
1863
+ tagsByDocId.set(page.id, resolveTagLabels(tagEntries, byId));
1597
1864
  }
1598
1865
  }
1599
1866
  }
@@ -1652,9 +1919,10 @@ export function registerDocTools(server, gql, defaults) {
1652
1919
  Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
1653
1920
  const meta = wsDoc.getMap("meta");
1654
1921
  const pages = getWorkspacePageEntries(meta);
1922
+ const { options, byId } = getWorkspaceTagOptionMaps(meta);
1655
1923
  const tagCounts = new Map();
1656
- for (const tag of getStringArray(getTagArray(meta))) {
1657
- const normalized = tag.trim();
1924
+ for (const option of options) {
1925
+ const normalized = option.value.trim();
1658
1926
  if (!normalized || tagCounts.has(normalized)) {
1659
1927
  continue;
1660
1928
  }
@@ -1662,7 +1930,8 @@ export function registerDocTools(server, gql, defaults) {
1662
1930
  }
1663
1931
  for (const page of pages) {
1664
1932
  const uniqueTags = new Set();
1665
- for (const tag of getStringArray(page.tagsArray)) {
1933
+ const resolved = resolveTagLabels(getStringArray(page.tagsArray), byId);
1934
+ for (const tag of resolved) {
1666
1935
  const normalized = tag.trim();
1667
1936
  if (!normalized) {
1668
1937
  continue;
@@ -1713,18 +1982,22 @@ export function registerDocTools(server, gql, defaults) {
1713
1982
  Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
1714
1983
  const meta = wsDoc.getMap("meta");
1715
1984
  const pages = getWorkspacePageEntries(meta);
1985
+ const { byId } = getWorkspaceTagOptionMaps(meta);
1716
1986
  const docs = pages
1717
1987
  .map((page) => {
1718
- const tags = getStringArray(page.tagsArray);
1988
+ const rawTags = getStringArray(page.tagsArray);
1989
+ const tags = resolveTagLabels(rawTags, byId);
1719
1990
  return {
1720
1991
  id: page.id,
1721
1992
  title: page.title,
1722
1993
  createDate: page.createDate,
1723
1994
  updatedDate: page.updatedDate,
1724
1995
  tags,
1996
+ rawTags,
1725
1997
  };
1726
1998
  })
1727
- .filter((page) => hasTag(page.tags, tag, ignoreCase));
1999
+ .filter((page) => hasTag(page.tags, tag, ignoreCase) || hasTag(page.rawTags, tag, ignoreCase))
2000
+ .map(({ rawTags: _rawTags, ...page }) => page);
1728
2001
  return text({
1729
2002
  workspaceId,
1730
2003
  tag,
@@ -1765,11 +2038,10 @@ export function registerDocTools(server, gql, defaults) {
1765
2038
  Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
1766
2039
  const prevSV = Y.encodeStateVector(wsDoc);
1767
2040
  const meta = wsDoc.getMap("meta");
1768
- const registry = ensureTagArray(meta);
1769
- if (findTagIndex(registry, tag, true) >= 0) {
2041
+ const { created } = ensureWorkspaceTagOption(meta, tag);
2042
+ if (!created) {
1770
2043
  return text({ workspaceId, tag, created: false });
1771
2044
  }
1772
- registry.push([tag]);
1773
2045
  const delta = Y.encodeStateAsUpdate(wsDoc, prevSV);
1774
2046
  await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(delta).toString("base64"));
1775
2047
  return text({ workspaceId, tag, created: true });
@@ -1809,17 +2081,11 @@ export function registerDocTools(server, gql, defaults) {
1809
2081
  if (!page) {
1810
2082
  throw new Error(`docId ${parsed.docId} is not present in workspace ${workspaceId}`);
1811
2083
  }
2084
+ const { option, created: optionCreated } = ensureWorkspaceTagOption(wsMeta, tag);
1812
2085
  const pageTags = ensureTagArray(page.entry);
1813
- const existedInDoc = findTagIndex(pageTags, tag, true) >= 0;
1814
- if (!existedInDoc) {
1815
- pageTags.push([tag]);
1816
- }
1817
- const registry = ensureTagArray(wsMeta);
1818
- const existedInRegistry = findTagIndex(registry, tag, true) >= 0;
1819
- if (!existedInRegistry) {
1820
- registry.push([tag]);
1821
- }
1822
- if (!existedInDoc || !existedInRegistry) {
2086
+ const pageSync = syncTagArrayToOption(pageTags, tag, option);
2087
+ const wsChanged = optionCreated || pageSync.changed;
2088
+ if (wsChanged) {
1823
2089
  const wsDelta = Y.encodeStateAsUpdate(wsDoc, wsPrevSV);
1824
2090
  await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(wsDelta).toString("base64"));
1825
2091
  }
@@ -1835,20 +2101,20 @@ export function registerDocTools(server, gql, defaults) {
1835
2101
  const docPrevSV = Y.encodeStateVector(doc);
1836
2102
  const docMeta = doc.getMap("meta");
1837
2103
  const docTags = ensureTagArray(docMeta);
1838
- const existedInDocMeta = findTagIndex(docTags, tag, true) >= 0;
1839
- if (!existedInDocMeta) {
1840
- docTags.push([tag]);
2104
+ const docSync = syncTagArrayToOption(docTags, tag, option);
2105
+ if (docSync.changed) {
1841
2106
  const docDelta = Y.encodeStateAsUpdate(doc, docPrevSV);
1842
2107
  await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(docDelta).toString("base64"));
1843
2108
  }
1844
2109
  docMetaSynced = true;
1845
2110
  }
2111
+ const { byId } = getWorkspaceTagOptionMaps(wsMeta);
1846
2112
  return text({
1847
2113
  workspaceId,
1848
2114
  docId: parsed.docId,
1849
2115
  tag,
1850
- added: !existedInDoc,
1851
- tags: getStringArray(pageTags),
2116
+ added: !pageSync.existed,
2117
+ tags: resolveTagLabels(getStringArray(pageTags), byId),
1852
2118
  docMetaSynced,
1853
2119
  warning,
1854
2120
  });
@@ -1889,10 +2155,11 @@ export function registerDocTools(server, gql, defaults) {
1889
2155
  if (!page) {
1890
2156
  throw new Error(`docId ${parsed.docId} is not present in workspace ${workspaceId}`);
1891
2157
  }
2158
+ const option = getWorkspaceTagOptionMaps(wsMeta).byValueLower.get(tag.toLocaleLowerCase()) || null;
1892
2159
  const pageTags = ensureTagArray(page.entry);
1893
- const pageTagIndex = findTagIndex(pageTags, tag, true);
1894
- if (pageTagIndex >= 0) {
1895
- pageTags.delete(pageTagIndex, 1);
2160
+ const pageTagIndexes = collectMatchingTagIndexes(pageTags, tag, option, true);
2161
+ const pageRemoved = deleteArrayIndexes(pageTags, pageTagIndexes);
2162
+ if (pageRemoved) {
1896
2163
  const wsDelta = Y.encodeStateAsUpdate(wsDoc, wsPrevSV);
1897
2164
  await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(wsDelta).toString("base64"));
1898
2165
  }
@@ -1908,20 +2175,20 @@ export function registerDocTools(server, gql, defaults) {
1908
2175
  const docPrevSV = Y.encodeStateVector(doc);
1909
2176
  const docMeta = doc.getMap("meta");
1910
2177
  const docTags = ensureTagArray(docMeta);
1911
- const docTagIndex = findTagIndex(docTags, tag, true);
1912
- if (docTagIndex >= 0) {
1913
- docTags.delete(docTagIndex, 1);
2178
+ const docTagIndexes = collectMatchingTagIndexes(docTags, tag, option, true);
2179
+ if (deleteArrayIndexes(docTags, docTagIndexes)) {
1914
2180
  const docDelta = Y.encodeStateAsUpdate(doc, docPrevSV);
1915
2181
  await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(docDelta).toString("base64"));
1916
2182
  }
1917
2183
  docMetaSynced = true;
1918
2184
  }
2185
+ const { byId } = getWorkspaceTagOptionMaps(wsMeta);
1919
2186
  return text({
1920
2187
  workspaceId,
1921
2188
  docId: parsed.docId,
1922
2189
  tag,
1923
- removed: pageTagIndex >= 0,
1924
- tags: getStringArray(pageTags),
2190
+ removed: pageRemoved,
2191
+ tags: resolveTagLabels(getStringArray(pageTags), byId),
1925
2192
  docMetaSynced,
1926
2193
  warning,
1927
2194
  });
@@ -1966,6 +2233,13 @@ export function registerDocTools(server, gql, defaults) {
1966
2233
  const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
1967
2234
  try {
1968
2235
  await joinWorkspace(socket, workspaceId);
2236
+ let tagOptionsById = new Map();
2237
+ const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
2238
+ if (workspaceSnapshot.missing) {
2239
+ const workspaceDoc = new Y.Doc();
2240
+ Y.applyUpdate(workspaceDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
2241
+ tagOptionsById = getWorkspaceTagOptionMaps(workspaceDoc.getMap("meta")).byId;
2242
+ }
1969
2243
  const snapshot = await loadDoc(socket, workspaceId, parsed.docId);
1970
2244
  if (!snapshot.missing) {
1971
2245
  return text({
@@ -1981,7 +2255,7 @@ export function registerDocTools(server, gql, defaults) {
1981
2255
  const doc = new Y.Doc();
1982
2256
  Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
1983
2257
  const meta = doc.getMap("meta");
1984
- const tags = getStringArray(getTagArray(meta));
2258
+ const tags = resolveTagLabels(getStringArray(getTagArray(meta)), tagOptionsById);
1985
2259
  const blocks = doc.getMap("blocks");
1986
2260
  const pageId = findBlockIdByFlavour(blocks, "affine:page");
1987
2261
  const noteId = findBlockIdByFlavour(blocks, "affine:note");
@@ -2190,6 +2464,13 @@ export function registerDocTools(server, gql, defaults) {
2190
2464
  const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
2191
2465
  try {
2192
2466
  await joinWorkspace(socket, workspaceId);
2467
+ let tagOptionsById = new Map();
2468
+ const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
2469
+ if (workspaceSnapshot.missing) {
2470
+ const wsDoc = new Y.Doc();
2471
+ Y.applyUpdate(wsDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
2472
+ tagOptionsById = getWorkspaceTagOptionMaps(wsDoc.getMap("meta")).byId;
2473
+ }
2193
2474
  const snapshot = await loadDoc(socket, workspaceId, parsed.docId);
2194
2475
  if (!snapshot.missing) {
2195
2476
  return text({
@@ -2208,7 +2489,7 @@ export function registerDocTools(server, gql, defaults) {
2208
2489
  }
2209
2490
  const doc = new Y.Doc();
2210
2491
  Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
2211
- const collected = collectDocForMarkdown(doc);
2492
+ const collected = collectDocForMarkdown(doc, tagOptionsById);
2212
2493
  const rendered = renderBlocksToMarkdown({
2213
2494
  rootBlockIds: collected.rootBlockIds,
2214
2495
  blocksById: collected.blocksById,
@@ -58,7 +58,7 @@ function createInitialWorkspaceData(workspaceName = 'New Workspace', avatar = ''
58
58
  const surfaceBlock = new Y.Map();
59
59
  surfaceBlock.set('sys:id', surfaceId);
60
60
  surfaceBlock.set('sys:flavour', 'affine:surface');
61
- surfaceBlock.set('sys:parent', pageId);
61
+ surfaceBlock.set('sys:parent', null);
62
62
  surfaceBlock.set('sys:children', new Y.Array());
63
63
  blocks.set(surfaceId, surfaceBlock);
64
64
  pageChildren.push([surfaceId]);
@@ -67,7 +67,7 @@ function createInitialWorkspaceData(workspaceName = 'New Workspace', avatar = ''
67
67
  const noteBlock = new Y.Map();
68
68
  noteBlock.set('sys:id', noteId);
69
69
  noteBlock.set('sys:flavour', 'affine:note');
70
- noteBlock.set('sys:parent', pageId);
70
+ noteBlock.set('sys:parent', null);
71
71
  noteBlock.set('prop:displayMode', 'DocAndEdgeless');
72
72
  noteBlock.set('prop:xywh', '[0,0,800,600]');
73
73
  noteBlock.set('prop:index', 'a0');
@@ -81,7 +81,7 @@ function createInitialWorkspaceData(workspaceName = 'New Workspace', avatar = ''
81
81
  const paragraphBlock = new Y.Map();
82
82
  paragraphBlock.set('sys:id', paragraphId);
83
83
  paragraphBlock.set('sys:flavour', 'affine:paragraph');
84
- paragraphBlock.set('sys:parent', noteId);
84
+ paragraphBlock.set('sys:parent', null);
85
85
  paragraphBlock.set('sys:children', new Y.Array());
86
86
  paragraphBlock.set('prop:type', 'text');
87
87
  const paragraphText = new Y.Text();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "affine-mcp-server",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
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.",
@@ -38,6 +38,7 @@
38
38
  "test:e2e": "bash tests/run-e2e.sh",
39
39
  "test:db-create": "node tests/test-database-creation.mjs",
40
40
  "test:bearer": "node tests/test-bearer-auth.mjs",
41
+ "test:tag-visibility": "node tests/test-tag-visibility.mjs",
41
42
  "test:playwright": "npx playwright test --config tests/playwright/playwright.config.ts",
42
43
  "pack:check": "npm pack --dry-run",
43
44
  "ci": "npm run build && npm run test:tool-manifest && npm run pack:check",