affine-mcp-server 1.12.0 → 2.0.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.
@@ -1,9 +1,11 @@
1
1
  import { z } from "zod";
2
- import { text } from "../util/mcp.js";
2
+ import { generateKeyBetween } from "fractional-indexing";
3
+ import { receipt, text } from "../util/mcp.js";
3
4
  import { wsUrlFromGraphQLEndpoint, connectWorkspaceSocket, joinWorkspace, loadDoc, pushDocUpdate, deleteDoc as wsDeleteDoc } from "../ws.js";
4
5
  import * as Y from "yjs";
5
6
  import { parseMarkdownToOperations } from "../markdown/parse.js";
6
7
  import { renderBlocksToMarkdown } from "../markdown/render.js";
8
+ 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";
7
9
  const WorkspaceId = z.string().min(1, "workspaceId required");
8
10
  const DocId = z.string().min(1, "docId required");
9
11
  const MarkdownContent = z.string().min(1, "markdown required");
@@ -55,6 +57,54 @@ const APPEND_BLOCK_BOOKMARK_STYLE_VALUES = [
55
57
  const AppendBlockBookmarkStyle = z.enum(APPEND_BLOCK_BOOKMARK_STYLE_VALUES);
56
58
  const APPEND_BLOCK_DATA_VIEW_MODE_VALUES = ["table", "kanban"];
57
59
  const AppendBlockDataViewMode = z.enum(APPEND_BLOCK_DATA_VIEW_MODE_VALUES);
60
+ const DATABASE_INTENT_VALUES = ["task_board", "issue_tracker"];
61
+ const DatabaseIntent = z.enum(DATABASE_INTENT_VALUES);
62
+ const DATABASE_COLUMN_TYPE_VALUES = ["rich-text", "select", "multi-select", "number", "checkbox", "link", "date"];
63
+ const MARKDOWN_EXPORT_SUPPORTED_FLAVOURS = new Set([
64
+ "affine:paragraph",
65
+ "affine:list",
66
+ "affine:code",
67
+ "affine:divider",
68
+ "affine:bookmark",
69
+ "affine:embed-youtube",
70
+ "affine:embed-github",
71
+ "affine:embed-figma",
72
+ "affine:embed-loom",
73
+ "affine:embed-iframe",
74
+ "affine:image",
75
+ "affine:table",
76
+ "affine:callout",
77
+ "affine:note",
78
+ "affine:page",
79
+ "affine:surface",
80
+ ]);
81
+ const KNOWN_BLOCK_FLAVOURS = new Set([
82
+ "affine:page",
83
+ "affine:surface",
84
+ "affine:paragraph",
85
+ "affine:list",
86
+ "affine:code",
87
+ "affine:divider",
88
+ "affine:callout",
89
+ "affine:latex",
90
+ "affine:table",
91
+ "affine:bookmark",
92
+ "affine:image",
93
+ "affine:attachment",
94
+ "affine:embed-youtube",
95
+ "affine:embed-github",
96
+ "affine:embed-figma",
97
+ "affine:embed-loom",
98
+ "affine:embed-html",
99
+ "affine:embed-linked-doc",
100
+ "affine:embed-synced-doc",
101
+ "affine:embed-iframe",
102
+ "affine:database",
103
+ "affine:surface-ref",
104
+ "affine:frame",
105
+ "affine:edgeless-text",
106
+ "affine:note",
107
+ ]);
58
108
  function blockVersion(flavour) {
59
109
  switch (flavour) {
60
110
  case "affine:page":
@@ -471,6 +521,24 @@ export function registerDocTools(server, gql, defaults) {
471
521
  }
472
522
  return null;
473
523
  }
524
+ function pruneFromFrameChildElementIds(blocks, deletedIds) {
525
+ if (deletedIds.length === 0)
526
+ return;
527
+ const idSet = new Set(deletedIds);
528
+ for (const [, value] of blocks) {
529
+ if (!(value instanceof Y.Map))
530
+ continue;
531
+ if (value.get("sys:flavour") !== "affine:frame")
532
+ continue;
533
+ const owned = value.get("prop:childElementIds");
534
+ if (!(owned instanceof Y.Map))
535
+ continue;
536
+ for (const id of idSet) {
537
+ if (owned.has(id))
538
+ owned.delete(id);
539
+ }
540
+ }
541
+ }
474
542
  function ensureNoteBlock(blocks) {
475
543
  const existingNoteId = findBlockIdByFlavour(blocks, "affine:note");
476
544
  if (existingNoteId) {
@@ -480,20 +548,29 @@ export function registerDocTools(server, gql, defaults) {
480
548
  if (!pageId) {
481
549
  throw new Error("Document has no page block; unable to insert content.");
482
550
  }
551
+ // Mirror BlockSuite's createDefaultDoc shape so the editor doesn't re-seed
552
+ // its own default note alongside ours.
483
553
  const noteId = generateId();
484
554
  const note = new Y.Map();
485
555
  setSysFields(note, noteId, "affine:note");
486
556
  note.set("sys:parent", null);
487
- note.set("sys:children", new Y.Array());
488
- note.set("prop:xywh", "[0,0,800,95]");
557
+ const noteChildren = new Y.Array();
558
+ note.set("sys:children", noteChildren);
559
+ note.set("prop:xywh", DEFAULT_NOTE_XYWH);
489
560
  note.set("prop:index", "a0");
490
561
  note.set("prop:hidden", false);
491
562
  note.set("prop:displayMode", "both");
492
- const background = new Y.Map();
493
- background.set("light", "#ffffff");
494
- background.set("dark", "#252525");
495
- note.set("prop:background", background);
563
+ note.set("prop:background", buildDefaultNoteBackground());
496
564
  blocks.set(noteId, note);
565
+ const paragraphId = generateId();
566
+ const paragraph = new Y.Map();
567
+ setSysFields(paragraph, paragraphId, "affine:paragraph");
568
+ paragraph.set("sys:parent", null);
569
+ paragraph.set("sys:children", new Y.Array());
570
+ paragraph.set("prop:type", "text");
571
+ paragraph.set("prop:text", makeText(""));
572
+ blocks.set(paragraphId, paragraph);
573
+ noteChildren.push([paragraphId]);
497
574
  const page = blocks.get(pageId);
498
575
  let pageChildren = page.get("sys:children");
499
576
  if (!(pageChildren instanceof Y.Array)) {
@@ -503,6 +580,12 @@ export function registerDocTools(server, gql, defaults) {
503
580
  pageChildren.push([noteId]);
504
581
  return noteId;
505
582
  }
583
+ function buildDefaultNoteBackground() {
584
+ const map = new Y.Map();
585
+ map.set("light", "#ffffff");
586
+ map.set("dark", "#252525");
587
+ return map;
588
+ }
506
589
  function ensureSurfaceBlock(blocks) {
507
590
  const existingSurfaceId = findBlockIdByFlavour(blocks, "affine:surface");
508
591
  if (existingSurfaceId) {
@@ -786,9 +869,20 @@ export function registerDocTools(server, gql, defaults) {
786
869
  const design = parsed.design ?? "";
787
870
  const reference = (parsed.reference ?? "").trim();
788
871
  const refFlavour = (parsed.refFlavour ?? "").trim();
789
- const width = Number.isFinite(parsed.width) ? Math.max(1, Math.floor(parsed.width)) : 100;
790
- const height = Number.isFinite(parsed.height) ? Math.max(1, Math.floor(parsed.height)) : 100;
791
- const background = (parsed.background ?? "transparent").trim() || "transparent";
872
+ const x = Number.isFinite(parsed.x) ? Math.floor(parsed.x) : 0;
873
+ const y = Number.isFinite(parsed.y) ? Math.floor(parsed.y) : 0;
874
+ const widthProvided = Number.isFinite(parsed.width);
875
+ const heightProvided = Number.isFinite(parsed.height);
876
+ const width = widthProvided ? Math.max(1, Math.floor(parsed.width)) : 100;
877
+ let height = heightProvided ? Math.max(1, Math.floor(parsed.height)) : 100;
878
+ // Pre-inflate the stored height so stackAfter'd siblings don't overlap the
879
+ // note before the editor's ResizeObserver corrects it on first render.
880
+ if (typeInfo.type === "note" && !heightProvided && typeof parsed.markdown === "string" && parsed.markdown.length > 0) {
881
+ height = Math.max(height, estimateNoteHeightForMarkdown(parsed.markdown, widthProvided ? width : 400));
882
+ }
883
+ const background = typeof parsed.background === "string"
884
+ ? (parsed.background.trim() || "transparent")
885
+ : (parsed.background && typeof parsed.background === "object" ? parsed.background : "transparent");
792
886
  const sourceId = (parsed.sourceId ?? "").trim();
793
887
  const name = (parsed.name ?? "attachment").trim() || "attachment";
794
888
  const mimeType = (parsed.mimeType ?? "application/octet-stream").trim() || "application/octet-stream";
@@ -812,6 +906,8 @@ export function registerDocTools(server, gql, defaults) {
812
906
  design,
813
907
  reference,
814
908
  refFlavour,
909
+ x,
910
+ y,
815
911
  width,
816
912
  height,
817
913
  background,
@@ -834,6 +930,14 @@ export function registerDocTools(server, gql, defaults) {
834
930
  tableData,
835
931
  deltas: parsed.deltas,
836
932
  tableCellDeltas,
933
+ childElementIds: Array.isArray(parsed.childElementIds) ? parsed.childElementIds : undefined,
934
+ stackAfter: parsed.stackAfter,
935
+ padding: Number.isFinite(parsed.padding) ? Math.max(0, Math.floor(parsed.padding)) : undefined,
936
+ xProvided: Number.isFinite(parsed.x),
937
+ yProvided: Number.isFinite(parsed.y),
938
+ widthProvided,
939
+ heightProvided,
940
+ markdown: typeof parsed.markdown === "string" ? parsed.markdown : undefined,
837
941
  };
838
942
  validateNormalizedAppendBlockInput(normalized, parsed);
839
943
  return normalized;
@@ -991,6 +1095,28 @@ export function registerDocTools(server, gql, defaults) {
991
1095
  column.set("hide", hide);
992
1096
  return column;
993
1097
  }
1098
+ function replaceSelectColumnOptions(column, options) {
1099
+ let data = column.get("data");
1100
+ if (!(data instanceof Y.Map)) {
1101
+ data = new Y.Map();
1102
+ column.set("data", data);
1103
+ }
1104
+ let rawOptions = data.get("options");
1105
+ if (!(rawOptions instanceof Y.Array)) {
1106
+ rawOptions = new Y.Array();
1107
+ data.set("options", rawOptions);
1108
+ }
1109
+ else if (rawOptions.length > 0) {
1110
+ rawOptions.delete(0, rawOptions.length);
1111
+ }
1112
+ options.forEach((value, index) => {
1113
+ const option = new Y.Map();
1114
+ option.set("id", generateId());
1115
+ option.set("value", value);
1116
+ option.set("color", SELECT_COLORS[index % SELECT_COLORS.length]);
1117
+ rawOptions.push([option]);
1118
+ });
1119
+ }
994
1120
  function createDatabaseColumnDefinition(input) {
995
1121
  const column = new Y.Map();
996
1122
  column.set("id", input.id);
@@ -1012,6 +1138,46 @@ export function registerDocTools(server, gql, defaults) {
1012
1138
  }
1013
1139
  return column;
1014
1140
  }
1141
+ function addDatabaseColumnToBlock(dbBlock, spec) {
1142
+ const columns = dbBlock.get("prop:columns");
1143
+ if (!(columns instanceof Y.Array)) {
1144
+ throw new Error("Database has no columns array");
1145
+ }
1146
+ const currentDefs = readColumnDefs(dbBlock);
1147
+ const existing = currentDefs.find(column => column.name === spec.name);
1148
+ if (existing) {
1149
+ if (existing.type !== spec.type) {
1150
+ throw new Error(`Column '${spec.name}' already exists with type '${existing.type}'`);
1151
+ }
1152
+ return existing.id;
1153
+ }
1154
+ const columnId = generateId();
1155
+ columns.push([createDatabaseColumnDefinition({
1156
+ id: columnId,
1157
+ name: spec.name,
1158
+ type: spec.type,
1159
+ width: spec.width,
1160
+ options: spec.options,
1161
+ })]);
1162
+ const views = dbBlock.get("prop:views");
1163
+ if (views instanceof Y.Array) {
1164
+ views.forEach((view) => {
1165
+ if (!(view instanceof Y.Map)) {
1166
+ return;
1167
+ }
1168
+ const viewColumns = view.get("columns");
1169
+ if (!(viewColumns instanceof Y.Array)) {
1170
+ return;
1171
+ }
1172
+ const viewColumn = new Y.Map();
1173
+ viewColumn.set("id", columnId);
1174
+ viewColumn.set("hide", false);
1175
+ viewColumn.set("width", spec.width ?? 200);
1176
+ viewColumns.push([viewColumn]);
1177
+ });
1178
+ }
1179
+ return columnId;
1180
+ }
1015
1181
  function createPresetBackedDataViewBlock(blockId, titleText, viewMode, blockType) {
1016
1182
  const block = new Y.Map();
1017
1183
  setSysFields(block, blockId, "affine:database");
@@ -1098,7 +1264,7 @@ export function registerDocTools(server, gql, defaults) {
1098
1264
  ? "quote"
1099
1265
  : "text";
1100
1266
  block.set("prop:type", blockType);
1101
- block.set("prop:text", makeText(content));
1267
+ block.set("prop:text", makeText(normalized.deltas ?? content));
1102
1268
  return { blockId, block, flavour: "affine:paragraph", blockType };
1103
1269
  }
1104
1270
  case "list": {
@@ -1137,7 +1303,7 @@ export function registerDocTools(server, gql, defaults) {
1137
1303
  textBlock.set("sys:parent", null);
1138
1304
  textBlock.set("sys:children", new Y.Array());
1139
1305
  textBlock.set("prop:type", "text");
1140
- textBlock.set("prop:text", makeText(content));
1306
+ textBlock.set("prop:text", makeText(normalized.deltas ?? content));
1141
1307
  calloutChildren.push([textBlockId]);
1142
1308
  block.set("sys:children", calloutChildren);
1143
1309
  block.set("prop:icon", { type: "emoji", unicode: "💡" });
@@ -1455,10 +1621,15 @@ export function registerDocTools(server, gql, defaults) {
1455
1621
  block.set("sys:parent", null);
1456
1622
  block.set("sys:children", new Y.Array());
1457
1623
  block.set("prop:title", makeText(content || "Frame"));
1458
- block.set("prop:background", normalized.background);
1459
- block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1624
+ // 'transparent' matches FrameBlockSchema; any other value renders as a
1625
+ // solid fill (the border is separate).
1626
+ block.set("prop:background", normalized.background ?? "transparent");
1627
+ block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
1460
1628
  block.set("prop:index", "a0");
1461
- block.set("prop:childElementIds", new Y.Map());
1629
+ const childIds = new Y.Map();
1630
+ for (const id of normalized._frameOwnedIds ?? [])
1631
+ childIds.set(id, true);
1632
+ block.set("prop:childElementIds", childIds);
1462
1633
  block.set("prop:presentationIndex", "a0");
1463
1634
  block.set("prop:lockedBySelf", false);
1464
1635
  return { blockId, block, flavour: "affine:frame" };
@@ -1466,27 +1637,54 @@ export function registerDocTools(server, gql, defaults) {
1466
1637
  case "edgeless_text": {
1467
1638
  setSysFields(block, blockId, "affine:edgeless-text");
1468
1639
  block.set("sys:parent", null);
1469
- block.set("sys:children", new Y.Array());
1470
- block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1640
+ const edgelessTextChildren = new Y.Array();
1641
+ block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
1471
1642
  block.set("prop:index", "a0");
1472
1643
  block.set("prop:lockedBySelf", false);
1473
1644
  block.set("prop:scale", 1);
1474
1645
  block.set("prop:rotate", 0);
1475
1646
  block.set("prop:hasMaxWidth", false);
1476
1647
  block.set("prop:comments", undefined);
1477
- block.set("prop:color", "black");
1648
+ // Theme-adaptive token so canvas text stays legible in dark mode.
1649
+ block.set("prop:color", "--affine-text-primary-color");
1478
1650
  block.set("prop:fontFamily", "Inter");
1479
1651
  block.set("prop:fontStyle", "normal");
1480
1652
  block.set("prop:fontWeight", "regular");
1481
1653
  block.set("prop:textAlign", "left");
1482
- return { blockId, block, flavour: "affine:edgeless-text" };
1654
+ const edgelessTextExtraBlocks = [];
1655
+ if (content) {
1656
+ const paraId = generateId();
1657
+ const para = new Y.Map();
1658
+ setSysFields(para, paraId, "affine:paragraph");
1659
+ para.set("sys:parent", null);
1660
+ para.set("sys:children", new Y.Array());
1661
+ para.set("prop:type", "text");
1662
+ para.set("prop:text", makeText(normalized.deltas ?? content));
1663
+ edgelessTextChildren.push([paraId]);
1664
+ edgelessTextExtraBlocks.push({ blockId: paraId, block: para });
1665
+ }
1666
+ block.set("sys:children", edgelessTextChildren);
1667
+ return { blockId, block, flavour: "affine:edgeless-text", extraBlocks: edgelessTextExtraBlocks };
1483
1668
  }
1484
1669
  case "note": {
1485
1670
  setSysFields(block, blockId, "affine:note");
1486
1671
  block.set("sys:parent", null);
1487
- block.set("sys:children", new Y.Array());
1488
- block.set("prop:xywh", `[0,0,${normalized.width},${normalized.height}]`);
1489
- block.set("prop:background", normalized.background);
1672
+ const noteChildren = new Y.Array();
1673
+ block.set("prop:xywh", `[${normalized.x},${normalized.y},${normalized.width},${normalized.height}]`);
1674
+ // BlockSuite reads the adaptive-bg case as a Y.Map; a plain JS object
1675
+ // would serialize to a JSON string and break theme switching.
1676
+ const bg = normalized.background;
1677
+ if (bg && typeof bg === "object" && !Array.isArray(bg) && ("light" in bg || "dark" in bg)) {
1678
+ const bgMap = new Y.Map();
1679
+ if (typeof bg.light === "string")
1680
+ bgMap.set("light", bg.light);
1681
+ if (typeof bg.dark === "string")
1682
+ bgMap.set("dark", bg.dark);
1683
+ block.set("prop:background", bgMap);
1684
+ }
1685
+ else {
1686
+ block.set("prop:background", bg);
1687
+ }
1490
1688
  block.set("prop:index", "a0");
1491
1689
  block.set("prop:lockedBySelf", false);
1492
1690
  block.set("prop:hidden", false);
@@ -1500,7 +1698,152 @@ export function registerDocTools(server, gql, defaults) {
1500
1698
  edgeless.set("style", style);
1501
1699
  block.set("prop:edgeless", edgeless);
1502
1700
  block.set("prop:comments", undefined);
1503
- return { blockId, block, flavour: "affine:note" };
1701
+ const noteExtraBlocks = [];
1702
+ if (content) {
1703
+ const paraId = generateId();
1704
+ const para = new Y.Map();
1705
+ setSysFields(para, paraId, "affine:paragraph");
1706
+ para.set("sys:parent", null);
1707
+ para.set("sys:children", new Y.Array());
1708
+ para.set("prop:type", "text");
1709
+ para.set("prop:text", makeText(normalized.deltas ?? content));
1710
+ noteChildren.push([paraId]);
1711
+ noteExtraBlocks.push({ blockId: paraId, block: para });
1712
+ }
1713
+ block.set("sys:children", noteChildren);
1714
+ return { blockId, block, flavour: "affine:note", extraBlocks: noteExtraBlocks };
1715
+ }
1716
+ }
1717
+ }
1718
+ function resolveBlockBoundAsBound(blocks, blockId) {
1719
+ const b = blocks.get(blockId);
1720
+ if (b instanceof Y.Map) {
1721
+ const xywh = parseXywhString(b.get("prop:xywh"));
1722
+ if (xywh)
1723
+ return { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height };
1724
+ }
1725
+ return null;
1726
+ }
1727
+ function resolveEdgelessLayoutHints(blocks, normalized) {
1728
+ const defaultPadding = normalized.padding ?? 40;
1729
+ let placed = false;
1730
+ if (normalized.stackAfter) {
1731
+ const idList = Array.isArray(normalized.stackAfter.blockId)
1732
+ ? normalized.stackAfter.blockId
1733
+ : [normalized.stackAfter.blockId];
1734
+ const direction = normalized.stackAfter.direction ?? "down";
1735
+ const missing = [];
1736
+ const bounds = [];
1737
+ for (const id of idList) {
1738
+ const b = resolveBlockBoundAsBound(blocks, id);
1739
+ if (!b)
1740
+ missing.push(id);
1741
+ else
1742
+ bounds.push(b);
1743
+ }
1744
+ const ref = pickFurthestInDirection(bounds, direction);
1745
+ if (!ref) {
1746
+ throw new Error(`stackAfter: no blockIds resolved to xywh. Missing: ${JSON.stringify(missing)}`);
1747
+ }
1748
+ // Gap precedence: explicit `gap` > explicit `padding` > direction default
1749
+ // (horizontal larger because notes are wide-short and tight sideways).
1750
+ const isHorizontal = direction === "left" || direction === "right";
1751
+ const directionDefaultGap = isHorizontal ? DEFAULT_STACK_GAP_HORIZONTAL : DEFAULT_STACK_GAP_VERTICAL;
1752
+ const gap = normalized.stackAfter.gap
1753
+ ?? (normalized.padding !== undefined ? normalized.padding : directionDefaultGap);
1754
+ // Center on the anchor group's orthogonal-axis union; caller x/y wins.
1755
+ const isVertical = direction === "down" || direction === "up";
1756
+ let preserveX;
1757
+ let preserveY;
1758
+ if (normalized.xProvided === true) {
1759
+ preserveX = normalized.x;
1760
+ }
1761
+ else if (isVertical) {
1762
+ const minX = bounds.reduce((m, b) => Math.min(m, b.x), Infinity);
1763
+ const maxX = bounds.reduce((m, b) => Math.max(m, b.x + b.w), -Infinity);
1764
+ preserveX = Math.round((minX + maxX) / 2 - normalized.width / 2);
1765
+ }
1766
+ if (normalized.yProvided === true) {
1767
+ preserveY = normalized.y;
1768
+ }
1769
+ else if (!isVertical) {
1770
+ const minY = bounds.reduce((m, b) => Math.min(m, b.y), Infinity);
1771
+ const maxY = bounds.reduce((m, b) => Math.max(m, b.y + b.h), -Infinity);
1772
+ preserveY = Math.round((minY + maxY) / 2 - normalized.height / 2);
1773
+ }
1774
+ const { x, y } = stackRelativeTo(ref, { w: normalized.width, h: normalized.height }, { direction, gap, preserveX, preserveY });
1775
+ normalized.x = x;
1776
+ normalized.y = y;
1777
+ placed = true;
1778
+ }
1779
+ // prop:childElementIds accepts both surface-element ids and block ids —
1780
+ // that's what the editor writes when you drag a note into a frame.
1781
+ if (normalized.type === "frame" && normalized.childElementIds && normalized.childElementIds.length > 0) {
1782
+ const surfaceCtx = getSurfaceElementsValueMap(blocks, { create: false });
1783
+ const surfaceValueMap = surfaceCtx?.value ?? new Y.Map();
1784
+ const ownedIds = [];
1785
+ const missing = [];
1786
+ const kids = [];
1787
+ for (const id of normalized.childElementIds) {
1788
+ const resolved = resolveChildBound(surfaceValueMap, blocks, id);
1789
+ if (resolved.kind === "missing") {
1790
+ missing.push(id);
1791
+ }
1792
+ else {
1793
+ ownedIds.push(id);
1794
+ if (resolved.bound)
1795
+ kids.push(resolved.bound);
1796
+ }
1797
+ }
1798
+ if (ownedIds.length === 0) {
1799
+ throw new Error(`None of the ids in childElementIds were found: ${JSON.stringify(missing)}.`);
1800
+ }
1801
+ normalized._frameOwnedIds = ownedIds;
1802
+ normalized._frameMissing = missing;
1803
+ if (!normalized.widthProvided || !normalized.heightProvided || !normalized.xProvided || !normalized.yProvided) {
1804
+ const wrapped = encloseBounds(kids, { padding: defaultPadding, titleBand: 60 });
1805
+ if (wrapped) {
1806
+ if (!normalized.xProvided)
1807
+ normalized.x = wrapped.x;
1808
+ if (!normalized.yProvided)
1809
+ normalized.y = wrapped.y;
1810
+ if (!normalized.widthProvided)
1811
+ normalized.width = wrapped.w;
1812
+ if (!normalized.heightProvided)
1813
+ normalized.height = wrapped.h;
1814
+ placed = true;
1815
+ }
1816
+ }
1817
+ }
1818
+ // Auto-stack-below fallback: avoids dropping new edgeless blocks on top of
1819
+ // the seeded default note at [0,0,…] when the caller gave no placement.
1820
+ const isEdgelessBlock = normalized.type === "frame" ||
1821
+ normalized.type === "note" ||
1822
+ normalized.type === "edgeless_text";
1823
+ if (!placed && isEdgelessBlock && !normalized.yProvided) {
1824
+ const existing = [];
1825
+ for (const [, b] of blocks.entries()) {
1826
+ if (!(b instanceof Y.Map))
1827
+ continue;
1828
+ const flavour = b.get("sys:flavour");
1829
+ if (flavour !== "affine:note" &&
1830
+ flavour !== "affine:frame" &&
1831
+ flavour !== "affine:edgeless-text")
1832
+ continue;
1833
+ const xywh = parseXywhString(b.get("prop:xywh"));
1834
+ if (!xywh)
1835
+ continue;
1836
+ existing.push({ x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height });
1837
+ }
1838
+ const ref = pickFurthestInDirection(existing, "down");
1839
+ if (ref) {
1840
+ const { x, y } = stackRelativeTo(ref, { w: normalized.width, h: normalized.height }, {
1841
+ direction: "down",
1842
+ gap: defaultPadding,
1843
+ preserveX: normalized.xProvided === true ? normalized.x : undefined,
1844
+ });
1845
+ normalized.x = x;
1846
+ normalized.y = y;
1504
1847
  }
1505
1848
  }
1506
1849
  }
@@ -1521,6 +1864,7 @@ export function registerDocTools(server, gql, defaults) {
1521
1864
  }
1522
1865
  const prevSV = Y.encodeStateVector(doc);
1523
1866
  const blocks = doc.getMap("blocks");
1867
+ resolveEdgelessLayoutHints(blocks, normalized);
1524
1868
  const context = resolveInsertContext(blocks, normalized);
1525
1869
  const { blockId, block, flavour, blockType, extraBlocks } = createBlock(normalized);
1526
1870
  blocks.set(blockId, block);
@@ -1537,7 +1881,16 @@ export function registerDocTools(server, gql, defaults) {
1537
1881
  }
1538
1882
  const delta = Y.encodeStateAsUpdate(doc, prevSV);
1539
1883
  await pushDocUpdate(socket, workspaceId, normalized.docId, Buffer.from(delta).toString("base64"));
1540
- return { appended: true, blockId, flavour, blockType, normalizedType: normalized.type, legacyType: normalized.legacyType || null };
1884
+ return {
1885
+ appended: true,
1886
+ blockId,
1887
+ flavour,
1888
+ blockType,
1889
+ normalizedType: normalized.type,
1890
+ legacyType: normalized.legacyType || null,
1891
+ ownedIds: normalized._frameOwnedIds,
1892
+ missing: normalized._frameMissing,
1893
+ };
1541
1894
  }
1542
1895
  finally {
1543
1896
  socket.disconnect();
@@ -1561,6 +1914,7 @@ export function registerDocTools(server, gql, defaults) {
1561
1914
  type: "heading",
1562
1915
  text: operation.text,
1563
1916
  level: operation.level,
1917
+ deltas: operation.deltas,
1564
1918
  strict,
1565
1919
  placement,
1566
1920
  };
@@ -1570,6 +1924,7 @@ export function registerDocTools(server, gql, defaults) {
1570
1924
  docId,
1571
1925
  type: "paragraph",
1572
1926
  text: operation.text,
1927
+ deltas: operation.deltas,
1573
1928
  strict,
1574
1929
  placement,
1575
1930
  };
@@ -1579,6 +1934,7 @@ export function registerDocTools(server, gql, defaults) {
1579
1934
  docId,
1580
1935
  type: "quote",
1581
1936
  text: operation.text,
1937
+ deltas: operation.deltas,
1582
1938
  strict,
1583
1939
  placement,
1584
1940
  };
@@ -1588,6 +1944,7 @@ export function registerDocTools(server, gql, defaults) {
1588
1944
  docId,
1589
1945
  type: "callout",
1590
1946
  text: operation.text,
1947
+ deltas: operation.deltas,
1591
1948
  strict,
1592
1949
  placement,
1593
1950
  };
@@ -1862,6 +2219,330 @@ export function registerDocTools(server, gql, defaults) {
1862
2219
  blocksById,
1863
2220
  };
1864
2221
  }
2222
+ function collectDocBlockRows(doc) {
2223
+ const blocks = doc.getMap("blocks");
2224
+ const rows = [];
2225
+ for (const [id, raw] of blocks) {
2226
+ if (!(raw instanceof Y.Map)) {
2227
+ continue;
2228
+ }
2229
+ const textValue = asText(raw.get("prop:text"));
2230
+ rows.push({
2231
+ id: String(id),
2232
+ flavour: asStringOrNull(raw.get("sys:flavour")),
2233
+ type: asStringOrNull(raw.get("prop:type")),
2234
+ hasText: textValue.length > 0,
2235
+ hasUrl: asText(raw.get("prop:url")).trim().length > 0,
2236
+ hasSourceId: asText(raw.get("prop:sourceId")).trim().length > 0,
2237
+ childCount: childIdsFrom(raw.get("sys:children")).length,
2238
+ });
2239
+ }
2240
+ return rows;
2241
+ }
2242
+ function summarizeDocFidelity(doc, tagOptionsById = new Map()) {
2243
+ const collected = collectDocForMarkdown(doc, tagOptionsById);
2244
+ const rendered = renderBlocksToMarkdown({
2245
+ rootBlockIds: collected.rootBlockIds,
2246
+ blocksById: collected.blocksById,
2247
+ });
2248
+ const blockRows = collectDocBlockRows(doc);
2249
+ const flavourCounts = {};
2250
+ const unsupportedBlocks = [];
2251
+ const conditionallyRiskyBlocks = [];
2252
+ for (const block of blockRows) {
2253
+ const flavourKey = block.flavour || "unknown";
2254
+ flavourCounts[flavourKey] = (flavourCounts[flavourKey] ?? 0) + 1;
2255
+ if (!block.flavour) {
2256
+ unsupportedBlocks.push({ id: block.id, flavour: null, reason: "Block flavour is missing." });
2257
+ continue;
2258
+ }
2259
+ if (!KNOWN_BLOCK_FLAVOURS.has(block.flavour)) {
2260
+ unsupportedBlocks.push({
2261
+ id: block.id,
2262
+ flavour: block.flavour,
2263
+ reason: "Block flavour is unknown to this MCP build.",
2264
+ });
2265
+ continue;
2266
+ }
2267
+ if (!MARKDOWN_EXPORT_SUPPORTED_FLAVOURS.has(block.flavour)) {
2268
+ unsupportedBlocks.push({
2269
+ id: block.id,
2270
+ flavour: block.flavour,
2271
+ reason: "Markdown export does not have a native renderer for this flavour.",
2272
+ });
2273
+ continue;
2274
+ }
2275
+ if (block.flavour === "affine:image" && !block.hasSourceId) {
2276
+ conditionallyRiskyBlocks.push({
2277
+ id: block.id,
2278
+ flavour: block.flavour,
2279
+ reason: "Image block has no sourceId; markdown export will skip it.",
2280
+ });
2281
+ }
2282
+ if ((block.flavour === "affine:bookmark" ||
2283
+ block.flavour === "affine:embed-youtube" ||
2284
+ block.flavour === "affine:embed-github" ||
2285
+ block.flavour === "affine:embed-figma" ||
2286
+ block.flavour === "affine:embed-loom" ||
2287
+ block.flavour === "affine:embed-iframe") &&
2288
+ !block.hasUrl) {
2289
+ conditionallyRiskyBlocks.push({
2290
+ id: block.id,
2291
+ flavour: block.flavour,
2292
+ reason: "Embed/bookmark block has no URL; markdown export will skip it.",
2293
+ });
2294
+ }
2295
+ }
2296
+ const overallRisk = unsupportedBlocks.length > 0
2297
+ ? "high"
2298
+ : conditionallyRiskyBlocks.length > 0 || rendered.lossy
2299
+ ? "medium"
2300
+ : "low";
2301
+ return {
2302
+ title: collected.title || null,
2303
+ tags: collected.tags,
2304
+ rootBlockIds: collected.rootBlockIds,
2305
+ markdown: rendered.markdown,
2306
+ markdownWarnings: rendered.warnings,
2307
+ markdownLossy: rendered.lossy,
2308
+ flavourCounts,
2309
+ unsupportedBlocks,
2310
+ conditionallyRiskyBlocks,
2311
+ overallRisk,
2312
+ recommendedPath: overallRisk === "low"
2313
+ ? "markdown_export_ok"
2314
+ : overallRisk === "medium"
2315
+ ? "markdown_export_with_review"
2316
+ : "prefer_native_read_or_clone",
2317
+ stats: {
2318
+ blockCount: blockRows.length,
2319
+ markdownUnsupportedCount: rendered.stats.unsupportedCount,
2320
+ unsupportedBlockCount: unsupportedBlocks.length,
2321
+ conditionallyRiskyBlockCount: conditionallyRiskyBlocks.length,
2322
+ },
2323
+ };
2324
+ }
2325
+ function truncateTemplatePreview(text, maxLength = 140) {
2326
+ if (text.length <= maxLength) {
2327
+ return text;
2328
+ }
2329
+ return `${text.slice(0, maxLength - 1)}…`;
2330
+ }
2331
+ function substituteTemplateVariables(input, ctx) {
2332
+ return input.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (match, key) => {
2333
+ const value = ctx.variables[key];
2334
+ if (value === undefined) {
2335
+ ctx.unresolvedVariables.add(match);
2336
+ return match;
2337
+ }
2338
+ ctx.replacedVariableCount += 1;
2339
+ return value;
2340
+ });
2341
+ }
2342
+ function remapTemplateString(value, ctx) {
2343
+ const remapped = ctx.blockIdMap.get(value) ?? (value === ctx.sourceDocId ? ctx.targetDocId : value);
2344
+ return substituteTemplateVariables(remapped, ctx);
2345
+ }
2346
+ function cloneNativeTemplateValue(value, ctx, path) {
2347
+ if (typeof value === "string") {
2348
+ return remapTemplateString(value, ctx);
2349
+ }
2350
+ if (typeof value === "number" || typeof value === "boolean" || value === null || value === undefined) {
2351
+ return value;
2352
+ }
2353
+ if (value instanceof Y.Text) {
2354
+ const next = new Y.Text();
2355
+ let offset = 0;
2356
+ value.toDelta().forEach((delta) => {
2357
+ const insert = typeof delta?.insert === "string"
2358
+ ? substituteTemplateVariables(delta.insert, ctx)
2359
+ : delta?.insert;
2360
+ const attributes = delta?.attributes ? cloneNativeTemplateValue(delta.attributes, ctx, `${path}.attributes`) : undefined;
2361
+ if (typeof insert === "string") {
2362
+ next.insert(offset, insert, (attributes && typeof attributes === "object") ? attributes : {});
2363
+ offset += insert.length;
2364
+ }
2365
+ else if (insert !== undefined) {
2366
+ const text = String(insert);
2367
+ next.insert(offset, text, (attributes && typeof attributes === "object") ? attributes : {});
2368
+ offset += text.length;
2369
+ }
2370
+ });
2371
+ return next;
2372
+ }
2373
+ if (value instanceof Y.Array) {
2374
+ const next = new Y.Array();
2375
+ value.forEach((entry) => {
2376
+ next.push([cloneNativeTemplateValue(entry, ctx, `${path}[]`)]);
2377
+ });
2378
+ return next;
2379
+ }
2380
+ if (value instanceof Y.Map) {
2381
+ const next = new Y.Map();
2382
+ for (const [key, entry] of value) {
2383
+ next.set(String(key), cloneNativeTemplateValue(entry, ctx, `${path}.${String(key)}`));
2384
+ }
2385
+ return next;
2386
+ }
2387
+ if (Array.isArray(value)) {
2388
+ return value.map((entry, index) => cloneNativeTemplateValue(entry, ctx, `${path}[${index}]`));
2389
+ }
2390
+ if (value instanceof Date) {
2391
+ return new Date(value.getTime());
2392
+ }
2393
+ if (value instanceof RegExp) {
2394
+ return new RegExp(value.source, value.flags);
2395
+ }
2396
+ if (typeof value === "object") {
2397
+ const proto = Object.getPrototypeOf(value);
2398
+ if (proto === Object.prototype || proto === null) {
2399
+ const next = {};
2400
+ for (const [key, entry] of Object.entries(value)) {
2401
+ next[key] = cloneNativeTemplateValue(entry, ctx, `${path}.${key}`);
2402
+ }
2403
+ return next;
2404
+ }
2405
+ throw new Error(`Unsupported native template value at ${path}: ${value.constructor?.name || "unknown"}.`);
2406
+ }
2407
+ return value;
2408
+ }
2409
+ function scanNativeTemplateValue(value, path, issues, seen) {
2410
+ if (value === null || value === undefined) {
2411
+ return;
2412
+ }
2413
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
2414
+ return;
2415
+ }
2416
+ if (value instanceof Y.Text) {
2417
+ for (const delta of value.toDelta()) {
2418
+ if (delta?.attributes && typeof delta.attributes === "object") {
2419
+ scanNativeTemplateValue(delta.attributes, `${path}.attributes`, issues, seen);
2420
+ }
2421
+ }
2422
+ return;
2423
+ }
2424
+ if (value instanceof Y.Array) {
2425
+ if (seen.has(value)) {
2426
+ return;
2427
+ }
2428
+ seen.add(value);
2429
+ let index = 0;
2430
+ value.forEach((entry) => {
2431
+ scanNativeTemplateValue(entry, `${path}[${index}]`, issues, seen);
2432
+ index += 1;
2433
+ });
2434
+ return;
2435
+ }
2436
+ if (value instanceof Y.Map) {
2437
+ if (seen.has(value)) {
2438
+ return;
2439
+ }
2440
+ seen.add(value);
2441
+ for (const [key, entry] of value) {
2442
+ scanNativeTemplateValue(entry, `${path}.${String(key)}`, issues, seen);
2443
+ }
2444
+ return;
2445
+ }
2446
+ if (Array.isArray(value)) {
2447
+ if (seen.has(value)) {
2448
+ return;
2449
+ }
2450
+ seen.add(value);
2451
+ value.forEach((entry, index) => {
2452
+ scanNativeTemplateValue(entry, `${path}[${index}]`, issues, seen);
2453
+ });
2454
+ return;
2455
+ }
2456
+ if (value instanceof Date || value instanceof RegExp) {
2457
+ return;
2458
+ }
2459
+ if (typeof value === "object") {
2460
+ const proto = Object.getPrototypeOf(value);
2461
+ if (proto === Object.prototype || proto === null) {
2462
+ if (seen.has(value)) {
2463
+ return;
2464
+ }
2465
+ seen.add(value);
2466
+ for (const [key, entry] of Object.entries(value)) {
2467
+ scanNativeTemplateValue(entry, `${path}.${key}`, issues, seen);
2468
+ }
2469
+ return;
2470
+ }
2471
+ issues.push({
2472
+ path,
2473
+ reason: `Unsupported native template value type: ${value.constructor?.name || "unknown"}.`,
2474
+ });
2475
+ }
2476
+ }
2477
+ function summarizeNativeTemplateStructure(doc, workspaceId, templateDocId, tagLabels, supportIssues) {
2478
+ const meta = doc.getMap("meta");
2479
+ const blocks = doc.getMap("blocks");
2480
+ const pageId = findBlockIdByFlavour(blocks, "affine:page");
2481
+ const surfaceId = findBlockIdByFlavour(blocks, "affine:surface");
2482
+ const noteId = findBlockIdByFlavour(blocks, "affine:note");
2483
+ const visited = new Set();
2484
+ const summaries = [];
2485
+ const rootBlockIds = [];
2486
+ if (pageId) {
2487
+ const pageBlock = findBlockById(blocks, pageId);
2488
+ if (pageBlock) {
2489
+ rootBlockIds.push(...childIdsFrom(pageBlock.get("sys:children")));
2490
+ }
2491
+ }
2492
+ else if (noteId) {
2493
+ rootBlockIds.push(noteId);
2494
+ }
2495
+ if (rootBlockIds.length === 0) {
2496
+ for (const [id] of blocks) {
2497
+ rootBlockIds.push(String(id));
2498
+ }
2499
+ }
2500
+ const visit = (blockId) => {
2501
+ if (visited.has(blockId)) {
2502
+ return;
2503
+ }
2504
+ visited.add(blockId);
2505
+ const block = findBlockById(blocks, blockId);
2506
+ if (!block) {
2507
+ return;
2508
+ }
2509
+ const childIds = childIdsFrom(block.get("sys:children"));
2510
+ const textValue = asText(block.get("prop:text"));
2511
+ summaries.push({
2512
+ id: blockId,
2513
+ parentId: resolveBlockParentId(blocks, blockId),
2514
+ flavour: asStringOrNull(block.get("sys:flavour")),
2515
+ type: asStringOrNull(block.get("prop:type")),
2516
+ textPreview: textValue.length > 0 ? truncateTemplatePreview(textValue) : null,
2517
+ textLength: textValue.length,
2518
+ childIds,
2519
+ });
2520
+ for (const childId of childIds) {
2521
+ visit(childId);
2522
+ }
2523
+ };
2524
+ for (const rootId of rootBlockIds) {
2525
+ visit(rootId);
2526
+ }
2527
+ for (const [id] of blocks) {
2528
+ visit(String(id));
2529
+ }
2530
+ const title = asText(meta.get("title")) || "Untitled";
2531
+ return {
2532
+ workspaceId,
2533
+ templateDocId,
2534
+ title,
2535
+ tags: tagLabels,
2536
+ pageId,
2537
+ surfaceId,
2538
+ noteId,
2539
+ rootBlockIds,
2540
+ blockCount: summaries.length,
2541
+ blocks: summaries,
2542
+ nativeCloneSupported: supportIssues.length === 0,
2543
+ fallbackReasons: supportIssues.map(issue => `${issue.path}: ${issue.reason}`),
2544
+ };
2545
+ }
1865
2546
  async function applyMarkdownOperationsInternal(parsed) {
1866
2547
  const strict = parsed.strict !== false;
1867
2548
  const replaceExisting = parsed.replaceExisting === true;
@@ -1982,30 +2663,26 @@ export function registerDocTools(server, gql, defaults) {
1982
2663
  setSysFields(note, noteId, "affine:note");
1983
2664
  note.set("sys:parent", null);
1984
2665
  note.set("prop:displayMode", "both");
1985
- note.set("prop:xywh", "[0,0,800,95]");
2666
+ note.set("prop:xywh", DEFAULT_NOTE_XYWH);
1986
2667
  note.set("prop:index", "a0");
1987
2668
  note.set("prop:hidden", false);
1988
- const background = new Y.Map();
1989
- background.set("light", "#ffffff");
1990
- background.set("dark", "#252525");
1991
- note.set("prop:background", background);
2669
+ note.set("prop:background", buildDefaultNoteBackground());
1992
2670
  const noteChildren = new Y.Array();
1993
2671
  note.set("sys:children", noteChildren);
1994
2672
  blocks.set(noteId, note);
1995
2673
  children.push([noteId]);
1996
- if (parsed.content) {
1997
- const paraId = generateId();
1998
- const para = new Y.Map();
1999
- setSysFields(para, paraId, "affine:paragraph");
2000
- para.set("sys:parent", null);
2001
- para.set("sys:children", new Y.Array());
2002
- para.set("prop:type", "text");
2003
- const paragraphText = new Y.Text();
2674
+ const paraId = generateId();
2675
+ const para = new Y.Map();
2676
+ setSysFields(para, paraId, "affine:paragraph");
2677
+ para.set("sys:parent", null);
2678
+ para.set("sys:children", new Y.Array());
2679
+ para.set("prop:type", "text");
2680
+ const paragraphText = new Y.Text();
2681
+ if (parsed.content)
2004
2682
  paragraphText.insert(0, parsed.content);
2005
- para.set("prop:text", paragraphText);
2006
- blocks.set(paraId, para);
2007
- noteChildren.push([paraId]);
2008
- }
2683
+ para.set("prop:text", paragraphText);
2684
+ blocks.set(paraId, para);
2685
+ noteChildren.push([paraId]);
2009
2686
  const meta = ydoc.getMap("meta");
2010
2687
  meta.set("id", docId);
2011
2688
  meta.set("title", title);
@@ -2035,62 +2712,443 @@ export function registerDocTools(server, gql, defaults) {
2035
2712
  const wsDelta = Y.encodeStateAsUpdate(wsDoc, prevSV);
2036
2713
  const wsDeltaBase64 = Buffer.from(wsDelta).toString("base64");
2037
2714
  await pushDocUpdate(socket, workspaceId, workspaceId, wsDeltaBase64);
2038
- return { workspaceId, docId, title };
2715
+ return {
2716
+ workspaceId,
2717
+ docId,
2718
+ title,
2719
+ parentDocId: null,
2720
+ linkedToParent: false,
2721
+ warnings: [],
2722
+ };
2039
2723
  }
2040
2724
  finally {
2041
2725
  socket.disconnect();
2042
2726
  }
2043
2727
  }
2044
- const listDocsHandler = async (parsed) => {
2045
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
2046
- if (!workspaceId) {
2047
- throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
2728
+ async function finalizeDocPlacement(parsed) {
2729
+ const parentDocId = parsed.parentDocId?.trim();
2730
+ if (!parentDocId) {
2731
+ return { parentDocId: null, linkedToParent: false, warnings: [] };
2048
2732
  }
2049
- const query = `query ListDocs($workspaceId: String!, $first: Int, $offset: Int, $after: String){ workspace(id:$workspaceId){ docs(pagination:{first:$first, offset:$offset, after:$after}){ totalCount pageInfo{ hasNextPage endCursor } edges{ cursor node{ id workspaceId title summary public defaultRole createdAt updatedAt } } } } }`;
2050
- const data = await gql.request(query, { workspaceId, first: parsed.first, offset: parsed.offset, after: parsed.after });
2051
- const docs = data.workspace.docs;
2052
- const tagsByDocId = new Map();
2053
- const titlesByDocId = new Map();
2054
- let workspacePageCount = null;
2055
- let workspacePageIds = null;
2056
- const deletedDocIds = new Set();
2733
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
2734
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
2735
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
2057
2736
  try {
2058
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
2059
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
2060
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
2061
- try {
2062
- await joinWorkspace(socket, workspaceId);
2063
- const snapshot = await loadDoc(socket, workspaceId, workspaceId);
2064
- if (snapshot.missing) {
2065
- const wsDoc = new Y.Doc();
2066
- Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
2067
- const meta = wsDoc.getMap("meta");
2068
- const pages = getWorkspacePageEntries(meta);
2069
- workspacePageCount = pages.length;
2070
- workspacePageIds = new Set(pages.map(page => page.id));
2071
- const { byId } = getWorkspaceTagOptionMaps(meta);
2072
- for (const page of pages) {
2073
- if (page.title) {
2074
- titlesByDocId.set(page.id, page.title);
2075
- }
2076
- const tagEntries = getStringArray(page.tagsArray);
2077
- tagsByDocId.set(page.id, resolveTagLabels(tagEntries, byId));
2078
- }
2079
- }
2080
- const graphEdges = Array.isArray(docs?.edges) ? docs.edges : [];
2081
- if (workspacePageIds && graphEdges.length > workspacePageIds.size) {
2082
- for (const edge of graphEdges) {
2083
- const nodeId = edge?.node?.id;
2084
- if (typeof nodeId !== "string" || workspacePageIds.has(nodeId)) {
2085
- continue;
2086
- }
2087
- const edgeSnapshot = await loadDoc(socket, workspaceId, nodeId);
2088
- const edgeExists = Boolean(edgeSnapshot.missing || edgeSnapshot.state || edgeSnapshot.timestamp);
2089
- if (!edgeExists) {
2090
- deletedDocIds.add(nodeId);
2091
- }
2092
- }
2093
- }
2737
+ await joinWorkspace(socket, parsed.workspaceId);
2738
+ const workspaceSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.workspaceId);
2739
+ if (!workspaceSnapshot.missing) {
2740
+ return {
2741
+ parentDocId,
2742
+ linkedToParent: false,
2743
+ warnings: [`${parsed.context}: workspace metadata could not be loaded to verify parent doc "${parentDocId}". Link it manually.`],
2744
+ };
2745
+ }
2746
+ const workspaceDoc = new Y.Doc();
2747
+ Y.applyUpdate(workspaceDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
2748
+ const parentExists = getWorkspacePageEntries(workspaceDoc.getMap("meta")).some(page => page.id === parentDocId);
2749
+ if (!parentExists) {
2750
+ return {
2751
+ parentDocId,
2752
+ linkedToParent: false,
2753
+ warnings: [`${parsed.context}: parent doc "${parentDocId}" was not found in workspace "${parsed.workspaceId}". Doc was left at the workspace root.`],
2754
+ };
2755
+ }
2756
+ try {
2757
+ await appendBlockInternal({
2758
+ workspaceId: parsed.workspaceId,
2759
+ docId: parentDocId,
2760
+ type: "embed_linked_doc",
2761
+ pageId: parsed.docId,
2762
+ });
2763
+ return { parentDocId, linkedToParent: true, warnings: [] };
2764
+ }
2765
+ catch {
2766
+ return {
2767
+ parentDocId,
2768
+ linkedToParent: false,
2769
+ warnings: [`${parsed.context}: doc created but could not be linked to parent doc "${parentDocId}". Link it manually.`],
2770
+ };
2771
+ }
2772
+ }
2773
+ finally {
2774
+ socket.disconnect();
2775
+ }
2776
+ }
2777
+ function createDocSkeleton(title, docId) {
2778
+ const doc = new Y.Doc();
2779
+ const blocks = doc.getMap("blocks");
2780
+ const pageId = generateId();
2781
+ const page = new Y.Map();
2782
+ setSysFields(page, pageId, "affine:page");
2783
+ const titleText = new Y.Text();
2784
+ titleText.insert(0, title);
2785
+ page.set("prop:title", titleText);
2786
+ page.set("sys:children", new Y.Array());
2787
+ blocks.set(pageId, page);
2788
+ const surfaceId = generateId();
2789
+ const surface = new Y.Map();
2790
+ setSysFields(surface, surfaceId, "affine:surface");
2791
+ surface.set("sys:parent", null);
2792
+ surface.set("sys:children", new Y.Array());
2793
+ const elements = new Y.Map();
2794
+ elements.set("type", "$blocksuite:internal:native$");
2795
+ elements.set("value", new Y.Map());
2796
+ surface.set("prop:elements", elements);
2797
+ blocks.set(surfaceId, surface);
2798
+ page.get("sys:children").push([surfaceId]);
2799
+ const noteId = generateId();
2800
+ const note = new Y.Map();
2801
+ setSysFields(note, noteId, "affine:note");
2802
+ note.set("sys:parent", null);
2803
+ note.set("prop:displayMode", "both");
2804
+ note.set("prop:xywh", DEFAULT_NOTE_XYWH);
2805
+ note.set("prop:index", "a0");
2806
+ note.set("prop:hidden", false);
2807
+ note.set("prop:background", buildDefaultNoteBackground());
2808
+ const skeletonNoteChildren = new Y.Array();
2809
+ note.set("sys:children", skeletonNoteChildren);
2810
+ blocks.set(noteId, note);
2811
+ page.get("sys:children").push([noteId]);
2812
+ const skeletonParaId = generateId();
2813
+ const skeletonPara = new Y.Map();
2814
+ setSysFields(skeletonPara, skeletonParaId, "affine:paragraph");
2815
+ skeletonPara.set("sys:parent", null);
2816
+ skeletonPara.set("sys:children", new Y.Array());
2817
+ skeletonPara.set("prop:type", "text");
2818
+ skeletonPara.set("prop:text", new Y.Text());
2819
+ blocks.set(skeletonParaId, skeletonPara);
2820
+ skeletonNoteChildren.push([skeletonParaId]);
2821
+ const meta = doc.getMap("meta");
2822
+ meta.set("id", docId);
2823
+ meta.set("title", title);
2824
+ meta.set("createDate", Date.now());
2825
+ meta.set("tags", new Y.Array());
2826
+ return { doc, blocks, pageId, surfaceId, noteId };
2827
+ }
2828
+ function makeWorkspacePageEntry(docId, title) {
2829
+ const entry = new Y.Map();
2830
+ entry.set("id", docId);
2831
+ entry.set("title", title);
2832
+ entry.set("createDate", Date.now());
2833
+ entry.set("tags", new Y.Array());
2834
+ return entry;
2835
+ }
2836
+ function defaultSemanticSections(pageType) {
2837
+ switch (pageType) {
2838
+ case "meeting_notes":
2839
+ return [
2840
+ { title: "Attendees" },
2841
+ { title: "Agenda" },
2842
+ { title: "Notes" },
2843
+ { title: "Action Items" },
2844
+ ];
2845
+ case "project_hub":
2846
+ return [
2847
+ { title: "Overview" },
2848
+ { title: "Milestones" },
2849
+ { title: "Risks" },
2850
+ { title: "References" },
2851
+ ];
2852
+ case "spec_page":
2853
+ return [
2854
+ { title: "Context" },
2855
+ { title: "Goals" },
2856
+ { title: "Non-Goals" },
2857
+ { title: "Proposal" },
2858
+ { title: "Open Questions" },
2859
+ ];
2860
+ case "wiki_page":
2861
+ default:
2862
+ return [
2863
+ { title: "Summary" },
2864
+ { title: "Details" },
2865
+ { title: "Related Resources" },
2866
+ ];
2867
+ }
2868
+ }
2869
+ function normalizeSemanticSections(pageType, sections) {
2870
+ const source = sections?.length ? sections : defaultSemanticSections(pageType ?? "wiki_page");
2871
+ return source.map((section) => ({
2872
+ title: section.title.trim(),
2873
+ paragraphs: section.paragraphs?.map((paragraph) => paragraph.trim()).filter(Boolean),
2874
+ bullets: section.bullets?.map((bullet) => bullet.trim()).filter(Boolean),
2875
+ callouts: section.callouts?.map((callout) => callout.trim()).filter(Boolean),
2876
+ }));
2877
+ }
2878
+ function semanticSectionToAppendInputs(section) {
2879
+ const inputs = [
2880
+ {
2881
+ type: "heading",
2882
+ text: section.title,
2883
+ level: 2,
2884
+ },
2885
+ ];
2886
+ for (const paragraph of section.paragraphs ?? []) {
2887
+ inputs.push({
2888
+ type: "paragraph",
2889
+ text: paragraph,
2890
+ });
2891
+ }
2892
+ for (const bullet of section.bullets ?? []) {
2893
+ inputs.push({
2894
+ type: "list",
2895
+ text: bullet,
2896
+ style: "bulleted",
2897
+ });
2898
+ }
2899
+ for (const callout of section.callouts ?? []) {
2900
+ inputs.push({
2901
+ type: "callout",
2902
+ text: callout,
2903
+ });
2904
+ }
2905
+ return inputs;
2906
+ }
2907
+ function appendNativeBlocks(blocks, parentId, inputs, workspaceId, docId, strict = true, initialPlacement) {
2908
+ const parentBlock = findBlockById(blocks, parentId);
2909
+ if (!parentBlock) {
2910
+ throw new Error(`Target parent block '${parentId}' was not found.`);
2911
+ }
2912
+ let anchorPlacement = initialPlacement ?? { parentId };
2913
+ const blockIds = [];
2914
+ const headingIds = [];
2915
+ for (const input of inputs) {
2916
+ const normalized = normalizeAppendBlockInput({
2917
+ workspaceId,
2918
+ docId,
2919
+ strict,
2920
+ placement: anchorPlacement,
2921
+ ...input,
2922
+ });
2923
+ const context = resolveInsertContext(blocks, normalized);
2924
+ const { blockId, block, extraBlocks } = createBlock(normalized);
2925
+ blocks.set(blockId, block);
2926
+ if (Array.isArray(extraBlocks)) {
2927
+ for (const extra of extraBlocks) {
2928
+ blocks.set(extra.blockId, extra.block);
2929
+ }
2930
+ }
2931
+ if (context.insertIndex >= context.children.length) {
2932
+ context.children.push([blockId]);
2933
+ }
2934
+ else {
2935
+ context.children.insert(context.insertIndex, [blockId]);
2936
+ }
2937
+ blockIds.push(blockId);
2938
+ if (normalized.type === "heading") {
2939
+ headingIds.push(blockId);
2940
+ }
2941
+ anchorPlacement = { afterBlockId: blockId };
2942
+ }
2943
+ return { blockIds, headingIds };
2944
+ }
2945
+ function isHeadingBlock(block) {
2946
+ return block.get("sys:flavour") === "affine:paragraph" && /^h[1-6]$/.test(String(block.get("prop:type") || ""));
2947
+ }
2948
+ function getHeadingLevel(block) {
2949
+ const type = String(block.get("prop:type") || "");
2950
+ const match = type.match(/^h([1-6])$/);
2951
+ return match ? Number(match[1]) : null;
2952
+ }
2953
+ function normalizedText(value) {
2954
+ return richTextValueToString(value).trim().toLocaleLowerCase();
2955
+ }
2956
+ function findSectionInsertionIndex(blocks, noteId, sectionTitle) {
2957
+ const noteBlock = findBlockById(blocks, noteId);
2958
+ if (!noteBlock) {
2959
+ throw new Error(`Note block '${noteId}' was not found.`);
2960
+ }
2961
+ const children = childIdsFrom(noteBlock.get("sys:children"));
2962
+ const target = normalizedText(sectionTitle);
2963
+ for (let i = 0; i < children.length; i += 1) {
2964
+ const childBlock = findBlockById(blocks, children[i]);
2965
+ if (!childBlock || !isHeadingBlock(childBlock)) {
2966
+ continue;
2967
+ }
2968
+ if (normalizedText(childBlock.get("prop:text")) !== target) {
2969
+ continue;
2970
+ }
2971
+ const targetLevel = getHeadingLevel(childBlock) ?? 2;
2972
+ let endIndex = i + 1;
2973
+ while (endIndex < children.length) {
2974
+ const nextBlock = findBlockById(blocks, children[endIndex]);
2975
+ if (nextBlock && isHeadingBlock(nextBlock)) {
2976
+ const nextLevel = getHeadingLevel(nextBlock) ?? 2;
2977
+ if (nextLevel <= targetLevel) {
2978
+ break;
2979
+ }
2980
+ }
2981
+ endIndex += 1;
2982
+ }
2983
+ return endIndex;
2984
+ }
2985
+ throw new Error(`Section heading '${sectionTitle}' was not found.`);
2986
+ }
2987
+ async function commitNewDocument(socket, workspaceId, docId, title, doc) {
2988
+ const updateFull = Y.encodeStateAsUpdate(doc);
2989
+ await pushDocUpdate(socket, workspaceId, docId, Buffer.from(updateFull).toString("base64"));
2990
+ const wsDoc = new Y.Doc();
2991
+ const snapshot = await loadDoc(socket, workspaceId, workspaceId);
2992
+ if (snapshot.missing) {
2993
+ Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
2994
+ }
2995
+ const prevSV = Y.encodeStateVector(wsDoc);
2996
+ const wsMeta = wsDoc.getMap("meta");
2997
+ let pages = wsMeta.get("pages");
2998
+ if (!pages) {
2999
+ pages = new Y.Array();
3000
+ wsMeta.set("pages", pages);
3001
+ }
3002
+ pages.push([makeWorkspacePageEntry(docId, title)]);
3003
+ const wsDelta = Y.encodeStateAsUpdate(wsDoc, prevSV);
3004
+ await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(wsDelta).toString("base64"));
3005
+ }
3006
+ async function createSemanticPageInternal(parsed) {
3007
+ const workspaceId = parsed.workspaceId || defaults.workspaceId;
3008
+ if (!workspaceId) {
3009
+ throw new Error("workspaceId is required. Provide it or set AFFINE_WORKSPACE_ID.");
3010
+ }
3011
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3012
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3013
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3014
+ try {
3015
+ await joinWorkspace(socket, workspaceId);
3016
+ const docId = generateId();
3017
+ const title = parsed.title || "Untitled";
3018
+ const pageType = parsed.pageType ?? "wiki_page";
3019
+ const sections = normalizeSemanticSections(pageType, parsed.sections);
3020
+ const docShell = createDocSkeleton(title, docId);
3021
+ const { blockIds, headingIds } = appendNativeBlocks(docShell.blocks, docShell.noteId, sections.flatMap(semanticSectionToAppendInputs), workspaceId, docId);
3022
+ await commitNewDocument(socket, workspaceId, docId, title, docShell.doc);
3023
+ let parentLinked = false;
3024
+ const warnings = [];
3025
+ if (parsed.parentDocId) {
3026
+ try {
3027
+ await appendBlockInternal({
3028
+ workspaceId,
3029
+ docId: parsed.parentDocId,
3030
+ type: "embed_linked_doc",
3031
+ pageId: docId,
3032
+ });
3033
+ parentLinked = true;
3034
+ }
3035
+ catch {
3036
+ warnings.push(`Semantic page created but could not be linked to parent doc "${parsed.parentDocId}". Link it manually.`);
3037
+ }
3038
+ }
3039
+ return {
3040
+ workspaceId,
3041
+ docId,
3042
+ title,
3043
+ pageType,
3044
+ noteId: docShell.noteId,
3045
+ pageId: docShell.pageId,
3046
+ sectionHeadingIds: headingIds,
3047
+ blockIds,
3048
+ parentLinked,
3049
+ warnings,
3050
+ };
3051
+ }
3052
+ finally {
3053
+ socket.disconnect();
3054
+ }
3055
+ }
3056
+ async function appendSemanticSectionInternal(parsed) {
3057
+ const workspaceId = parsed.workspaceId || defaults.workspaceId;
3058
+ if (!workspaceId) {
3059
+ throw new Error("workspaceId is required. Provide it or set AFFINE_WORKSPACE_ID.");
3060
+ }
3061
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3062
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3063
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3064
+ try {
3065
+ await joinWorkspace(socket, workspaceId);
3066
+ const doc = new Y.Doc();
3067
+ const snapshot = await loadDoc(socket, workspaceId, parsed.docId);
3068
+ if (!snapshot.missing) {
3069
+ throw new Error(`Document ${parsed.docId} was not found in workspace ${workspaceId}.`);
3070
+ }
3071
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
3072
+ const prevSV = Y.encodeStateVector(doc);
3073
+ const blocks = doc.getMap("blocks");
3074
+ const noteId = ensureNoteBlock(blocks);
3075
+ const insertionIndex = parsed.afterSectionTitle
3076
+ ? findSectionInsertionIndex(blocks, noteId, parsed.afterSectionTitle)
3077
+ : childIdsFrom(findBlockById(blocks, noteId)?.get("sys:children")).length;
3078
+ const { blockIds, headingIds } = appendNativeBlocks(blocks, noteId, semanticSectionToAppendInputs({
3079
+ title: parsed.sectionTitle,
3080
+ paragraphs: parsed.paragraphs,
3081
+ bullets: parsed.bullets,
3082
+ callouts: parsed.callouts,
3083
+ }), workspaceId, parsed.docId, true, { parentId: noteId, index: insertionIndex });
3084
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
3085
+ await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
3086
+ return {
3087
+ workspaceId,
3088
+ docId: parsed.docId,
3089
+ noteId,
3090
+ sectionTitle: parsed.sectionTitle,
3091
+ sectionHeadingId: headingIds[0] || blockIds[0],
3092
+ afterSectionTitle: parsed.afterSectionTitle ?? null,
3093
+ blockIds,
3094
+ };
3095
+ }
3096
+ finally {
3097
+ socket.disconnect();
3098
+ }
3099
+ }
3100
+ const listDocsHandler = async (parsed) => {
3101
+ const workspaceId = parsed.workspaceId || defaults.workspaceId;
3102
+ if (!workspaceId) {
3103
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
3104
+ }
3105
+ const query = `query ListDocs($workspaceId: String!, $first: Int, $offset: Int, $after: String){ workspace(id:$workspaceId){ docs(pagination:{first:$first, offset:$offset, after:$after}){ totalCount pageInfo{ hasNextPage endCursor } edges{ cursor node{ id workspaceId title summary public defaultRole createdAt updatedAt } } } } }`;
3106
+ const data = await gql.request(query, { workspaceId, first: parsed.first, offset: parsed.offset, after: parsed.after });
3107
+ const docs = data.workspace.docs;
3108
+ const tagsByDocId = new Map();
3109
+ const titlesByDocId = new Map();
3110
+ let workspacePageCount = null;
3111
+ let workspacePageIds = null;
3112
+ const deletedDocIds = new Set();
3113
+ try {
3114
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3115
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3116
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3117
+ try {
3118
+ await joinWorkspace(socket, workspaceId);
3119
+ const snapshot = await loadDoc(socket, workspaceId, workspaceId);
3120
+ if (snapshot.missing) {
3121
+ const wsDoc = new Y.Doc();
3122
+ Y.applyUpdate(wsDoc, Buffer.from(snapshot.missing, "base64"));
3123
+ const meta = wsDoc.getMap("meta");
3124
+ const pages = getWorkspacePageEntries(meta);
3125
+ workspacePageCount = pages.length;
3126
+ workspacePageIds = new Set(pages.map(page => page.id));
3127
+ const { byId } = getWorkspaceTagOptionMaps(meta);
3128
+ for (const page of pages) {
3129
+ if (page.title) {
3130
+ titlesByDocId.set(page.id, page.title);
3131
+ }
3132
+ const tagEntries = getStringArray(page.tagsArray);
3133
+ tagsByDocId.set(page.id, resolveTagLabels(tagEntries, byId));
3134
+ }
3135
+ }
3136
+ const graphEdges = Array.isArray(docs?.edges) ? docs.edges : [];
3137
+ if (workspacePageIds && graphEdges.length > workspacePageIds.size) {
3138
+ for (const edge of graphEdges) {
3139
+ const nodeId = edge?.node?.id;
3140
+ if (typeof nodeId !== "string" || workspacePageIds.has(nodeId)) {
3141
+ continue;
3142
+ }
3143
+ const edgeSnapshot = await loadDoc(socket, workspaceId, nodeId);
3144
+ // Treat timestamp-only responses as deleted tombstones so list_docs can
3145
+ // hide stale GraphQL edges after delete_doc eventually converges.
3146
+ const edgeExists = Boolean(edgeSnapshot.missing || edgeSnapshot.state);
3147
+ if (!edgeExists) {
3148
+ deletedDocIds.add(nodeId);
3149
+ }
3150
+ }
3151
+ }
2094
3152
  }
2095
3153
  finally {
2096
3154
  socket.disconnect();
@@ -2717,6 +3775,123 @@ export function registerDocTools(server, gql, defaults) {
2717
3775
  includeMarkdown: z.boolean().optional().describe("If true, includes rendered markdown in the response. Equivalent to also calling export_doc_markdown."),
2718
3776
  },
2719
3777
  }, readDocHandler);
3778
+ const getCapabilitiesHandler = async () => {
3779
+ return text({
3780
+ server: {
3781
+ name: "affine-mcp",
3782
+ capabilityVersion: 1,
3783
+ },
3784
+ docs: {
3785
+ canonicalBlockTypes: [...APPEND_BLOCK_CANONICAL_TYPE_VALUES],
3786
+ legacyBlockAliases: Object.keys(APPEND_BLOCK_LEGACY_ALIAS_MAP),
3787
+ markdownImport: {
3788
+ supported: true,
3789
+ lossy: true,
3790
+ knownLosses: [
3791
+ "Nested markdown lists are flattened during import.",
3792
+ "Markdown images are converted into bookmark blocks unless blobs are uploaded separately.",
3793
+ "HTML blocks are imported as plain paragraph text.",
3794
+ ],
3795
+ },
3796
+ markdownExport: {
3797
+ supportedFlavours: [...MARKDOWN_EXPORT_SUPPORTED_FLAVOURS].sort(),
3798
+ lossyForUnsupportedFlavours: true,
3799
+ knownUnsupportedFlavours: [...KNOWN_BLOCK_FLAVOURS].filter(flavour => !MARKDOWN_EXPORT_SUPPORTED_FLAVOURS.has(flavour)).sort(),
3800
+ },
3801
+ highLevelAuthoring: {
3802
+ semanticPageComposer: true,
3803
+ nativeTemplateInstantiation: true,
3804
+ createDocWithPlacement: true,
3805
+ semanticSectionEditing: true,
3806
+ },
3807
+ edgelessCanvas: {
3808
+ blockMutation: true,
3809
+ surfaceElementMutation: true,
3810
+ canvasReadback: true,
3811
+ frameOwnershipMutation: true,
3812
+ },
3813
+ },
3814
+ database: {
3815
+ supported: true,
3816
+ columnTypes: [...DATABASE_COLUMN_TYPE_VALUES],
3817
+ initialViewModes: [...APPEND_BLOCK_DATA_VIEW_MODE_VALUES],
3818
+ advancedViewMutation: true,
3819
+ intentDrivenComposition: true,
3820
+ linkedDocRows: true,
3821
+ },
3822
+ workspace: {
3823
+ organizeToolsExperimental: true,
3824
+ ruleBackedCollections: true,
3825
+ workspaceBlueprints: true,
3826
+ },
3827
+ collaboration: {
3828
+ docComments: true,
3829
+ repliesListed: true,
3830
+ replyCreation: false,
3831
+ anchoredComments: false,
3832
+ selectionRangeComments: false,
3833
+ sharePolicyManagement: false,
3834
+ },
3835
+ export: {
3836
+ markdown: true,
3837
+ html: false,
3838
+ fidelityReport: true,
3839
+ snapshotBundle: false,
3840
+ },
3841
+ });
3842
+ };
3843
+ server.registerTool("get_capabilities", {
3844
+ title: "Get Capabilities",
3845
+ description: "Return machine-readable capability flags for this MCP server, including block, database, collaboration, and export support.",
3846
+ inputSchema: {},
3847
+ }, getCapabilitiesHandler);
3848
+ const analyzeDocFidelityHandler = async (parsed) => {
3849
+ const workspaceId = parsed.workspaceId || defaults.workspaceId;
3850
+ if (!workspaceId) {
3851
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
3852
+ }
3853
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3854
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3855
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3856
+ try {
3857
+ await joinWorkspace(socket, workspaceId);
3858
+ let tagOptionsById = new Map();
3859
+ const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
3860
+ if (workspaceSnapshot.missing) {
3861
+ const workspaceDoc = new Y.Doc();
3862
+ Y.applyUpdate(workspaceDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
3863
+ tagOptionsById = getWorkspaceTagOptionMaps(workspaceDoc.getMap("meta")).byId;
3864
+ }
3865
+ const snapshot = await loadDoc(socket, workspaceId, parsed.docId);
3866
+ if (!snapshot.missing) {
3867
+ return text({
3868
+ docId: parsed.docId,
3869
+ exists: false,
3870
+ unsupportedBlocks: [],
3871
+ conditionallyRiskyBlocks: [],
3872
+ });
3873
+ }
3874
+ const doc = new Y.Doc();
3875
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
3876
+ const summary = summarizeDocFidelity(doc, tagOptionsById);
3877
+ return text({
3878
+ docId: parsed.docId,
3879
+ exists: true,
3880
+ ...summary,
3881
+ });
3882
+ }
3883
+ finally {
3884
+ socket.disconnect();
3885
+ }
3886
+ };
3887
+ server.registerTool("analyze_doc_fidelity", {
3888
+ title: "Analyze Document Fidelity",
3889
+ description: "Inspect a document for markdown export fidelity risk, including unsupported AFFiNE block flavours and risky content paths.",
3890
+ inputSchema: {
3891
+ workspaceId: WorkspaceId.optional(),
3892
+ docId: DocId,
3893
+ },
3894
+ }, analyzeDocFidelityHandler);
2720
3895
  // move_doc: move a doc in the sidebar by removing its embed_linked_doc from the old parent
2721
3896
  // and adding it to the new parent. fromParentDocId is optional — if omitted, only adds to new parent.
2722
3897
  const moveDocHandler = async (parsed) => {
@@ -2784,7 +3959,13 @@ export function registerDocTools(server, gql, defaults) {
2784
3959
  type: "embed_linked_doc",
2785
3960
  pageId: parsed.docId,
2786
3961
  });
2787
- return text({ moved: true, docId: parsed.docId, toParentDocId: parsed.toParentDocId, removedFromParent });
3962
+ return receipt("doc.move", {
3963
+ workspaceId,
3964
+ moved: true,
3965
+ docId: parsed.docId,
3966
+ toParentDocId: parsed.toParentDocId,
3967
+ removedFromParent,
3968
+ });
2788
3969
  }
2789
3970
  finally {
2790
3971
  socket.disconnect();
@@ -2807,9 +3988,13 @@ export function registerDocTools(server, gql, defaults) {
2807
3988
  }
2808
3989
  const mutation = `mutation PublishDoc($workspaceId:String!,$docId:String!,$mode:PublicDocMode){ publishDoc(workspaceId:$workspaceId, docId:$docId, mode:$mode){ id workspaceId public mode } }`;
2809
3990
  const data = await gql.request(mutation, { workspaceId, docId: parsed.docId, mode: parsed.mode });
2810
- return text(data.publishDoc);
2811
- };
2812
- server.registerTool("publish_doc", {
3991
+ return receipt("doc.publish", {
3992
+ workspaceId,
3993
+ docId: parsed.docId,
3994
+ ...data.publishDoc,
3995
+ });
3996
+ };
3997
+ server.registerTool("publish_doc", {
2813
3998
  title: "Publish Document",
2814
3999
  description: "Publish a doc (make public).",
2815
4000
  inputSchema: {
@@ -2825,7 +4010,11 @@ export function registerDocTools(server, gql, defaults) {
2825
4010
  }
2826
4011
  const mutation = `mutation RevokeDoc($workspaceId:String!,$docId:String!){ revokePublicDoc(workspaceId:$workspaceId, docId:$docId){ id workspaceId public } }`;
2827
4012
  const data = await gql.request(mutation, { workspaceId, docId: parsed.docId });
2828
- return text(data.revokePublicDoc);
4013
+ return receipt("doc.revoke_public", {
4014
+ workspaceId,
4015
+ docId: parsed.docId,
4016
+ ...data.revokePublicDoc,
4017
+ });
2829
4018
  };
2830
4019
  server.registerTool("revoke_doc", {
2831
4020
  title: "Revoke Document",
@@ -2838,45 +4027,128 @@ export function registerDocTools(server, gql, defaults) {
2838
4027
  // CREATE DOC (high-level)
2839
4028
  const createDocHandler = async (parsed) => {
2840
4029
  const created = await createDocInternal(parsed);
2841
- return text({ docId: created.docId, title: created.title });
4030
+ const placement = await finalizeDocPlacement({
4031
+ workspaceId: created.workspaceId,
4032
+ docId: created.docId,
4033
+ parentDocId: parsed.parentDocId,
4034
+ context: "create_doc",
4035
+ });
4036
+ return receipt("doc.create", {
4037
+ workspaceId: created.workspaceId,
4038
+ docId: created.docId,
4039
+ title: created.title,
4040
+ parentDocId: placement.parentDocId,
4041
+ linkedToParent: placement.linkedToParent,
4042
+ warnings: mergeWarnings(created.warnings ?? [], placement.warnings),
4043
+ });
2842
4044
  };
2843
4045
  server.registerTool('create_doc', {
2844
4046
  title: 'Create Document',
2845
- description: 'Create a new AFFiNE document with optional content',
4047
+ description: 'Create a new AFFiNE document with optional content. If parentDocId is provided, the new doc is linked into the sidebar tree immediately.',
2846
4048
  inputSchema: {
2847
4049
  workspaceId: z.string().optional(),
2848
4050
  title: z.string().optional(),
2849
4051
  content: z.string().optional(),
4052
+ parentDocId: z.string().optional().describe("Optional parent doc to link the new doc under in the sidebar."),
2850
4053
  },
2851
4054
  }, createDocHandler);
2852
- // APPEND PARAGRAPH
2853
- const appendParagraphHandler = async (parsed) => {
2854
- const result = await appendBlockInternal({
2855
- workspaceId: parsed.workspaceId,
2856
- docId: parsed.docId,
2857
- type: "paragraph",
2858
- text: parsed.text,
4055
+ const semanticSectionSchema = z.object({
4056
+ title: z.string().min(1).describe("Semantic section title."),
4057
+ paragraphs: z.array(z.string().min(1)).optional().describe("Paragraphs to append under the section heading."),
4058
+ bullets: z.array(z.string().min(1)).optional().describe("Bulleted items to append under the section heading."),
4059
+ callouts: z.array(z.string().min(1)).optional().describe("Callout blocks to append under the section heading."),
4060
+ });
4061
+ const createSemanticPageHandler = async (parsed) => {
4062
+ const created = await createSemanticPageInternal(parsed);
4063
+ return text({
4064
+ workspaceId: created.workspaceId,
4065
+ docId: created.docId,
4066
+ title: created.title,
4067
+ pageType: created.pageType,
4068
+ pageId: created.pageId,
4069
+ noteId: created.noteId,
4070
+ sectionCount: created.sectionHeadingIds.length,
4071
+ sectionHeadingIds: created.sectionHeadingIds,
4072
+ blockIds: created.blockIds,
4073
+ parentLinked: created.parentLinked,
4074
+ warnings: created.warnings,
2859
4075
  });
2860
- return text({ appended: result.appended, paragraphId: result.blockId });
2861
4076
  };
2862
- server.registerTool('append_paragraph', {
2863
- title: 'Append Paragraph',
2864
- description: 'Append a text paragraph block to a document',
4077
+ server.registerTool("create_semantic_page", {
4078
+ title: "Create Semantic Page",
4079
+ description: "Create an AFFiNE-native page with intentional section structure and native block composition.",
2865
4080
  inputSchema: {
2866
- workspaceId: z.string().optional(),
2867
- docId: z.string(),
2868
- text: z.string(),
4081
+ workspaceId: WorkspaceId.optional(),
4082
+ title: z.string().optional().describe("Page title."),
4083
+ pageType: z.enum(["meeting_notes", "project_hub", "spec_page", "wiki_page"]).optional().describe("Semantic page template to seed default sections."),
4084
+ parentDocId: z.string().optional().describe("Optional parent doc to link the new page under in the sidebar."),
4085
+ sections: z.array(semanticSectionSchema).optional().describe("Optional explicit section structure. If omitted, the page type defaults are used."),
2869
4086
  },
2870
- }, appendParagraphHandler);
2871
- const appendBlockHandler = async (parsed) => {
2872
- const result = await appendBlockInternal(parsed);
4087
+ }, createSemanticPageHandler);
4088
+ const appendSemanticSectionHandler = async (parsed) => {
4089
+ const result = await appendSemanticSectionInternal(parsed);
2873
4090
  return text({
4091
+ workspaceId: result.workspaceId,
4092
+ docId: result.docId,
4093
+ noteId: result.noteId,
4094
+ sectionTitle: result.sectionTitle,
4095
+ sectionHeadingId: result.sectionHeadingId,
4096
+ afterSectionTitle: result.afterSectionTitle,
4097
+ blockIds: result.blockIds,
4098
+ appendedCount: result.blockIds.length,
4099
+ });
4100
+ };
4101
+ server.registerTool("append_semantic_section", {
4102
+ title: "Append Semantic Section",
4103
+ description: "Append a semantic section to an existing AFFiNE document by heading title and native block composition.",
4104
+ inputSchema: {
4105
+ workspaceId: WorkspaceId.optional(),
4106
+ docId: DocId,
4107
+ sectionTitle: z.string().min(1).describe("Heading text for the new semantic section."),
4108
+ afterSectionTitle: z.string().optional().describe("Optional existing section heading to append after."),
4109
+ paragraphs: z.array(z.string().min(1)).optional().describe("Paragraphs to append under the new section."),
4110
+ bullets: z.array(z.string().min(1)).optional().describe("Bulleted items to append under the new section."),
4111
+ callouts: z.array(z.string().min(1)).optional().describe("Callout blocks to append under the new section."),
4112
+ },
4113
+ }, appendSemanticSectionHandler);
4114
+ const appendBlockHandler = async (parsed) => {
4115
+ // Drop `text` when `markdown` is set so markdown-parsed children don't
4116
+ // sit next to a stale one-paragraph echo.
4117
+ const shouldApplyMarkdown = parsed.type === "note" && !!parsed.markdown;
4118
+ const coreParsed = shouldApplyMarkdown ? { ...parsed, text: undefined } : parsed;
4119
+ const result = await appendBlockInternal(coreParsed);
4120
+ let markdownApplied;
4121
+ if (shouldApplyMarkdown && result.appended && result.blockId) {
4122
+ const parsedMd = parseMarkdownToOperations(parsed.markdown);
4123
+ if (parsedMd.operations.length > 0) {
4124
+ const applied = await applyMarkdownOperationsInternal({
4125
+ workspaceId: parsed.workspaceId || defaults.workspaceId,
4126
+ docId: parsed.docId,
4127
+ operations: parsedMd.operations,
4128
+ strict: parsed.strict,
4129
+ placement: { parentId: result.blockId },
4130
+ });
4131
+ markdownApplied = {
4132
+ appendedCount: applied.appendedCount,
4133
+ skippedCount: applied.skippedCount,
4134
+ blockIds: applied.blockIds,
4135
+ warnings: parsedMd.warnings,
4136
+ };
4137
+ }
4138
+ }
4139
+ return receipt("doc.append_block", {
4140
+ workspaceId: parsed.workspaceId || defaults.workspaceId || null,
4141
+ docId: parsed.docId,
2874
4142
  appended: result.appended,
2875
4143
  blockId: result.blockId,
2876
4144
  flavour: result.flavour,
2877
4145
  type: result.blockType || null,
4146
+ blockType: result.blockType || null,
2878
4147
  normalizedType: result.normalizedType,
2879
4148
  legacyType: result.legacyType,
4149
+ ...(result.ownedIds ? { ownedIds: result.ownedIds } : {}),
4150
+ ...(result.missing ? { missing: result.missing } : {}),
4151
+ ...(markdownApplied ? { markdown: markdownApplied } : {}),
2880
4152
  });
2881
4153
  };
2882
4154
  server.registerTool("append_block", {
@@ -2894,9 +4166,19 @@ export function registerDocTools(server, gql, defaults) {
2894
4166
  design: z.string().optional().describe("Design payload for embed_html"),
2895
4167
  reference: z.string().optional().describe("Target id for surface_ref"),
2896
4168
  refFlavour: z.string().optional().describe("Target flavour for surface_ref (e.g. affine:frame)"),
2897
- width: z.number().int().min(1).max(10000).optional().describe("Width for frame/edgeless_text/note"),
2898
- height: z.number().int().min(1).max(10000).optional().describe("Height for frame/edgeless_text/note"),
2899
- background: z.string().optional().describe("Background for frame/note"),
4169
+ x: z.number().int().optional().describe("X position on the edgeless canvas for frame/edgeless_text/note (default 0). Prefer ≥40px between sibling bounds; BlockSuite does not auto-arrange."),
4170
+ y: z.number().int().optional().describe("Y position on the edgeless canvas for frame/edgeless_text/note (default 0)."),
4171
+ width: z.number().int().min(1).max(10000).optional().describe("Width for frame/edgeless_text/note."),
4172
+ height: z.number().int().min(1).max(10000).optional().describe("Height for frame/edgeless_text/note. When `markdown` is set and height is omitted, an over-estimate is computed from the content — AFFiNE's render-time ResizeObserver corrects `prop:xywh` to the true DOM-measured height on first browser open."),
4173
+ background: z.any().optional().describe("Background for frame/note. Frame default 'transparent'. For notes, prefer AFFiNE's adaptive `--affine-note-background-<color>` family — `blue` / `purple` / `yellow` / `green` / `teal` / `red` / `orange` / `magenta` / `grey` / `white` / `black`. For specific per-theme colors, pass a `{light, dark}` hex object like `{light:'#fff', dark:'#252525'}`."),
4174
+ markdown: z.string().optional().describe("When type='note', parse this markdown into heading/paragraph/list/code child blocks inside the note (BlockSuite-native: mirrors what happens when you paste markdown into an edgeless note). Takes precedence over 'text' for note children. Ignored for other block types."),
4175
+ childElementIds: z.array(z.string()).optional().describe("For type='frame' only. The frame's contents. Accepts ids of surface elements (shapes/connectors/groups) AND edgeless blocks (notes/frames/edgeless-text) — BlockSuite's prop:childElementIds holds both, matching what the editor writes when you drag a note into a frame. Dragging the frame drags every owned member. Ids that don't resolve come back under 'missing'. When width/height are omitted the frame is sized to the union of resolvable child bounds + padding + a 30px title band."),
4176
+ stackAfter: z.object({
4177
+ blockId: z.union([z.string(), z.array(z.string())]).describe("Block(s) to stack relative to. String = a single anchor; array = pick whichever is furthest in the stack direction (bottommost for 'down', rightmost for 'right', etc.)."),
4178
+ direction: z.enum(["down", "up", "right", "left"]).optional().describe("Direction (default 'down')"),
4179
+ gap: z.number().int().optional().describe("Gap in px between the anchor and the new block. Default is direction-aware: 80 for left/right, 40 for down/up — mirrors native-flowchart spacing where the flow axis gets more breathing room than the cross axis. Explicit `padding` on the block overrides this default; explicit `gap` wins over both."),
4180
+ }).optional().describe("Layout helper — position this block relative to one or more existing edgeless blocks. Picks the furthest anchor in `direction` for the stack axis, and centers the new block on the anchor group's union on the orthogonal axis (matches how BlockSuite aligns selection-derived blocks; reduces to inherit-anchor-x when widths match). Caller-provided x/y on the orthogonal axis still wins. Works for frame/note/edgeless_text. Example: `stackAfter: { blockId: [f1, f2, f3], gap: 80 }` stacks below whichever column frame ends lowest, centered across all three. Note heights shift at first render (page-root grows with the title, content notes shrink/grow with their children); give extra gap and fix up with `update_edgeless_block` if the down/right chain drifts."),
4181
+ padding: z.number().int().optional().describe("Default padding (px) for `childElementIds` auto-sizing on frames (each side, plus +30px title band) and fallback gap for `stackAfter` (default 40)."),
2900
4182
  sourceId: z.string().optional().describe("Blob source id for image/attachment"),
2901
4183
  name: z.string().optional().describe("Attachment file name"),
2902
4184
  mimeType: z.string().optional().describe("Attachment mime type"),
@@ -3002,8 +4284,86 @@ export function registerDocTools(server, gql, defaults) {
3002
4284
  includeFrontmatter: z.boolean().optional(),
3003
4285
  },
3004
4286
  }, exportDocMarkdownHandler);
4287
+ const exportWithFidelityReportHandler = async (parsed) => {
4288
+ const workspaceId = parsed.workspaceId || defaults.workspaceId;
4289
+ if (!workspaceId) {
4290
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
4291
+ }
4292
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4293
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4294
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4295
+ try {
4296
+ await joinWorkspace(socket, workspaceId);
4297
+ let tagOptionsById = new Map();
4298
+ const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
4299
+ if (workspaceSnapshot.missing) {
4300
+ const wsDoc = new Y.Doc();
4301
+ Y.applyUpdate(wsDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
4302
+ tagOptionsById = getWorkspaceTagOptionMaps(wsDoc.getMap("meta")).byId;
4303
+ }
4304
+ const snapshot = await loadDoc(socket, workspaceId, parsed.docId);
4305
+ if (!snapshot.missing) {
4306
+ return text({
4307
+ docId: parsed.docId,
4308
+ exists: false,
4309
+ markdown: "",
4310
+ fidelity: {
4311
+ overallRisk: "high",
4312
+ recommendedPath: "prefer_native_read_or_clone",
4313
+ unsupportedBlocks: [],
4314
+ conditionallyRiskyBlocks: [],
4315
+ },
4316
+ });
4317
+ }
4318
+ const doc = new Y.Doc();
4319
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
4320
+ const summary = summarizeDocFidelity(doc, tagOptionsById);
4321
+ let markdown = summary.markdown;
4322
+ if (parsed.includeFrontmatter) {
4323
+ const escapedTitle = (summary.title || "Untitled").replace(/\"/g, "\\\"");
4324
+ const frontmatterLines = [
4325
+ "---",
4326
+ `docId: \"${parsed.docId}\"`,
4327
+ `title: \"${escapedTitle}\"`,
4328
+ "tags:",
4329
+ ...(summary.tags.length > 0 ? summary.tags.map(tag => ` - \"${tag.replace(/\"/g, "\\\"")}\"`) : [" -"]),
4330
+ `lossy: ${summary.markdownLossy ? "true" : "false"}`,
4331
+ `fidelityRisk: \"${summary.overallRisk}\"`,
4332
+ "---",
4333
+ ];
4334
+ markdown = `${frontmatterLines.join("\n")}\n\n${markdown}`;
4335
+ }
4336
+ return text({
4337
+ docId: parsed.docId,
4338
+ exists: true,
4339
+ markdown,
4340
+ fidelity: {
4341
+ overallRisk: summary.overallRisk,
4342
+ recommendedPath: summary.recommendedPath,
4343
+ unsupportedBlocks: summary.unsupportedBlocks,
4344
+ conditionallyRiskyBlocks: summary.conditionallyRiskyBlocks,
4345
+ markdownWarnings: summary.markdownWarnings,
4346
+ markdownLossy: summary.markdownLossy,
4347
+ flavourCounts: summary.flavourCounts,
4348
+ stats: summary.stats,
4349
+ },
4350
+ });
4351
+ }
4352
+ finally {
4353
+ socket.disconnect();
4354
+ }
4355
+ };
4356
+ server.registerTool("export_with_fidelity_report", {
4357
+ title: "Export With Fidelity Report",
4358
+ description: "Export document markdown together with a structured fidelity report that highlights markdown loss risk and unsupported AFFiNE-native content.",
4359
+ inputSchema: {
4360
+ workspaceId: WorkspaceId.optional(),
4361
+ docId: DocId,
4362
+ includeFrontmatter: z.boolean().optional(),
4363
+ },
4364
+ }, exportWithFidelityReportHandler);
3005
4365
  // Core logic for creating a doc from markdown — returns structured data, no MCP envelope.
3006
- // Used by both createDocFromMarkdownHandler and batchCreateDocsHandler.
4366
+ // Used by createDocFromMarkdownHandler and internal markdown-based flows.
3007
4367
  const createDocFromMarkdownCore = async (parsed) => {
3008
4368
  const parsedMarkdown = parseMarkdownToOperations(parsed.markdown);
3009
4369
  let operations = [...parsedMarkdown.operations];
@@ -3035,36 +4395,23 @@ export function registerDocTools(server, gql, defaults) {
3035
4395
  strict: parsed.strict,
3036
4396
  });
3037
4397
  }
3038
- // If parentDocId is provided, embed the new doc into the parent so it
3039
- // appears in the sidebar as a child instead of being an orphan.
3040
- let linkedToParent = false;
3041
- if (parsed.parentDocId) {
3042
- try {
3043
- await appendBlockInternal({
3044
- workspaceId: created.workspaceId,
3045
- docId: parsed.parentDocId,
3046
- type: "embed_linked_doc",
3047
- pageId: created.docId,
3048
- });
3049
- linkedToParent = true;
3050
- }
3051
- catch {
3052
- // Non-fatal: doc was created, just not linked. Warn below.
3053
- }
3054
- }
4398
+ const placement = await finalizeDocPlacement({
4399
+ workspaceId: created.workspaceId,
4400
+ docId: created.docId,
4401
+ parentDocId: parsed.parentDocId,
4402
+ context: "create_doc_from_markdown",
4403
+ });
3055
4404
  const applyWarnings = [];
3056
4405
  if (applied.skippedCount > 0) {
3057
4406
  applyWarnings.push(`${applied.skippedCount} markdown block(s) could not be applied to AFFiNE and were skipped.`);
3058
4407
  }
3059
- if (parsed.parentDocId && !linkedToParent) {
3060
- applyWarnings.push(`Doc created but could not be linked to parent doc "${parsed.parentDocId}". Link it manually.`);
3061
- }
3062
4408
  return {
3063
4409
  workspaceId: created.workspaceId,
3064
4410
  docId: created.docId,
3065
4411
  title: created.title,
3066
- linkedToParent,
3067
- warnings: mergeWarnings(parsedMarkdown.warnings, applyWarnings),
4412
+ parentDocId: placement.parentDocId,
4413
+ linkedToParent: placement.linkedToParent,
4414
+ warnings: mergeWarnings(parsedMarkdown.warnings, applyWarnings, placement.warnings),
3068
4415
  lossy: parsedMarkdown.lossy || applied.skippedCount > 0,
3069
4416
  stats: {
3070
4417
  parsedBlocks: parsedMarkdown.operations.length,
@@ -3074,7 +4421,7 @@ export function registerDocTools(server, gql, defaults) {
3074
4421
  };
3075
4422
  };
3076
4423
  const createDocFromMarkdownHandler = async (parsed) => {
3077
- return text(await createDocFromMarkdownCore(parsed));
4424
+ return receipt("doc.create_from_markdown", await createDocFromMarkdownCore(parsed));
3078
4425
  };
3079
4426
  server.registerTool("create_doc_from_markdown", {
3080
4427
  title: "Create Document From Markdown",
@@ -3087,51 +4434,53 @@ export function registerDocTools(server, gql, defaults) {
3087
4434
  parentDocId: z.string().optional().describe("If provided, the new doc is automatically embedded into this parent doc as a linked child (visible in sidebar)."),
3088
4435
  },
3089
4436
  }, createDocFromMarkdownHandler);
3090
- // batch_create_docs: create up to 20 docs in one call
3091
- const batchCreateDocsHandler = async (parsed) => {
4437
+ const createDocFromTemplateCore = async (parsed) => {
3092
4438
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
3093
4439
  if (!workspaceId)
3094
4440
  throw new Error("workspaceId is required.");
3095
- if (!Array.isArray(parsed.docs) || parsed.docs.length === 0)
3096
- throw new Error("docs array is required.");
3097
- if (parsed.docs.length > 20)
3098
- throw new Error("Maximum 20 docs per batch.");
3099
- const results = [];
3100
- for (const item of parsed.docs) {
3101
- try {
3102
- const d = await createDocFromMarkdownCore({ workspaceId, title: item.title, markdown: item.markdown });
3103
- // Link to parent if provided
3104
- let linkedToParent = false;
3105
- if (item.parentDocId) {
3106
- try {
3107
- await appendBlockInternal({ workspaceId, docId: item.parentDocId, type: "embed_linked_doc", pageId: d.docId });
3108
- linkedToParent = true;
3109
- }
3110
- catch {
3111
- d.warnings?.push(`Doc created but could not be linked to parent "${item.parentDocId}". Link it manually.`);
3112
- }
3113
- }
3114
- results.push({ title: d.title, docId: d.docId, linkedToParent, warnings: d.warnings ?? [] });
4441
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
4442
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
4443
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
4444
+ try {
4445
+ await joinWorkspace(socket, workspaceId);
4446
+ const snap = await loadDoc(socket, workspaceId, parsed.templateDocId);
4447
+ if (!snap.missing)
4448
+ throw new Error(`Template doc ${parsed.templateDocId} not found.`);
4449
+ const doc = new Y.Doc();
4450
+ Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
4451
+ const collected = collectDocForMarkdown(doc, new Map());
4452
+ const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
4453
+ let markdown = rendered.markdown;
4454
+ const vars = parsed.variables ?? {};
4455
+ for (const [key, value] of Object.entries(vars)) {
4456
+ const pattern = new RegExp(`\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}}`, "g");
4457
+ markdown = markdown.replace(pattern, value);
3115
4458
  }
3116
- catch (err) {
3117
- results.push({ title: item.title, docId: "", linkedToParent: false, warnings: [`Failed: ${err?.message ?? String(err)}`] });
4459
+ const unfilled = [...markdown.matchAll(/\{\{\s*[\w.-]+\s*\}\}/g)].map(match => match[0]);
4460
+ socket.disconnect();
4461
+ const created = await createDocFromMarkdownCore({ workspaceId, title: parsed.title, markdown, parentDocId: parsed.parentDocId });
4462
+ return {
4463
+ workspaceId,
4464
+ sourceTemplateDocId: parsed.templateDocId,
4465
+ docId: created.docId,
4466
+ title: created.title,
4467
+ parentDocId: created.parentDocId,
4468
+ linkedToParent: created.linkedToParent,
4469
+ cloneMode: "markdown-roundtrip",
4470
+ lossy: Boolean(created.lossy ?? (created.warnings?.length ?? 0) > 0),
4471
+ warnings: created.warnings ?? [],
4472
+ stats: created.stats ?? null,
4473
+ unfilledVariables: unfilled,
4474
+ };
4475
+ }
4476
+ catch (err) {
4477
+ try {
4478
+ socket.disconnect();
3118
4479
  }
4480
+ catch { /* already disconnected */ }
4481
+ throw err;
3119
4482
  }
3120
- const failed = results.filter(r => !r.docId).length;
3121
- return text({ created: results.length - failed, failed, results });
3122
4483
  };
3123
- server.registerTool("batch_create_docs", {
3124
- title: "Batch Create Documents",
3125
- description: "Create multiple AFFiNE documents in a single call. Each doc can optionally be linked to a parent (parentDocId) to appear in the sidebar. Max 20 docs per batch.",
3126
- inputSchema: {
3127
- workspaceId: z.string().optional(),
3128
- docs: z.array(z.object({
3129
- title: z.string().describe("Document title."),
3130
- markdown: z.string().describe("Markdown content."),
3131
- parentDocId: z.string().optional().describe("Parent doc ID — if provided, the new doc is embedded under this parent in the sidebar."),
3132
- })).min(1).max(20).describe("Array of docs to create (max 20)."),
3133
- },
3134
- }, batchCreateDocsHandler);
3135
4484
  const appendMarkdownHandler = async (parsed) => {
3136
4485
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
3137
4486
  if (!workspaceId) {
@@ -3148,7 +4497,7 @@ export function registerDocTools(server, gql, defaults) {
3148
4497
  const applyWarnings = applied.skippedCount > 0
3149
4498
  ? [`${applied.skippedCount} markdown block(s) could not be applied to AFFiNE and were skipped.`]
3150
4499
  : [];
3151
- return text({
4500
+ return receipt("doc.append_markdown", {
3152
4501
  workspaceId,
3153
4502
  docId: parsed.docId,
3154
4503
  appended: applied.appendedCount > 0,
@@ -3198,7 +4547,7 @@ export function registerDocTools(server, gql, defaults) {
3198
4547
  const applyWarnings = applied.skippedCount > 0
3199
4548
  ? [`${applied.skippedCount} markdown block(s) could not be applied to AFFiNE and were skipped.`]
3200
4549
  : [];
3201
- return text({
4550
+ return receipt("doc.replace_with_markdown", {
3202
4551
  workspaceId,
3203
4552
  docId: parsed.docId,
3204
4553
  replaced: true,
@@ -3255,7 +4604,11 @@ export function registerDocTools(server, gql, defaults) {
3255
4604
  await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(wsDelta).toString('base64'));
3256
4605
  // delete doc content
3257
4606
  wsDeleteDoc(socket, workspaceId, parsed.docId);
3258
- return text({ deleted: true });
4607
+ return receipt("doc.delete", {
4608
+ workspaceId,
4609
+ docId: parsed.docId,
4610
+ deleted: true,
4611
+ });
3259
4612
  }
3260
4613
  finally {
3261
4614
  socket.disconnect();
@@ -3266,194 +4619,6 @@ export function registerDocTools(server, gql, defaults) {
3266
4619
  description: 'Delete a document and remove from workspace list',
3267
4620
  inputSchema: { workspaceId: z.string().optional(), docId: z.string() },
3268
4621
  }, deleteDocHandler);
3269
- // ─── cleanup_orphan_embeds ──────────────────────────────────────────────────
3270
- const cleanupOrphanEmbedsHandler = async (parsed) => {
3271
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
3272
- if (!workspaceId)
3273
- throw new Error("workspaceId is required.");
3274
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3275
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3276
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3277
- try {
3278
- await joinWorkspace(socket, workspaceId);
3279
- const snap = await loadDoc(socket, workspaceId, parsed.docId);
3280
- if (!snap.missing)
3281
- throw new Error(`Doc ${parsed.docId} not found.`);
3282
- const doc = new Y.Doc();
3283
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
3284
- const blocks = doc.getMap("blocks");
3285
- const orphans = [];
3286
- for (const [blockId, raw] of blocks) {
3287
- if (!(raw instanceof Y.Map))
3288
- continue;
3289
- if (raw.get("sys:flavour") !== "affine:embed-linked-doc")
3290
- continue;
3291
- const targetId = raw.get("prop:pageId");
3292
- if (typeof targetId !== "string" || !targetId) {
3293
- orphans.push({ blockId, targetDocId: targetId ?? "" });
3294
- continue;
3295
- }
3296
- const targetSnap = await loadDoc(socket, workspaceId, targetId);
3297
- if (!targetSnap.missing)
3298
- orphans.push({ blockId, targetDocId: targetId });
3299
- }
3300
- if (parsed.dryRun || orphans.length === 0) {
3301
- return text({ docId: parsed.docId, dryRun: parsed.dryRun ?? false, orphansFound: orphans.length, orphans });
3302
- }
3303
- const prevSV = Y.encodeStateVector(doc);
3304
- for (const { blockId } of orphans) {
3305
- for (const [, parentRaw] of blocks) {
3306
- if (!(parentRaw instanceof Y.Map))
3307
- continue;
3308
- const children = parentRaw.get("sys:children");
3309
- if (!(children instanceof Y.Array))
3310
- continue;
3311
- const ids = childIdsFrom(children);
3312
- const idx = ids.indexOf(blockId);
3313
- if (idx !== -1) {
3314
- children.delete(idx, 1);
3315
- break;
3316
- }
3317
- }
3318
- blocks.delete(blockId);
3319
- }
3320
- const delta = Y.encodeStateAsUpdate(doc, prevSV);
3321
- await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
3322
- return text({ docId: parsed.docId, dryRun: false, orphansRemoved: orphans.length, orphans });
3323
- }
3324
- finally {
3325
- socket.disconnect();
3326
- }
3327
- };
3328
- server.registerTool("cleanup_orphan_embeds", {
3329
- title: "Cleanup Orphan Embed Links",
3330
- description: "Remove embed_linked_doc blocks that point to deleted/non-existent docs. Use dryRun=true to preview without making changes.",
3331
- inputSchema: {
3332
- workspaceId: z.string().optional(),
3333
- docId: z.string().describe("The doc to clean up orphan embeds from."),
3334
- dryRun: z.boolean().optional().describe("If true, only report orphans without deleting (default: false)."),
3335
- },
3336
- }, cleanupOrphanEmbedsHandler);
3337
- // ─── find_and_replace ───────────────────────────────────────────────────────
3338
- const findAndReplaceHandler = async (parsed) => {
3339
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
3340
- if (!workspaceId)
3341
- throw new Error("workspaceId is required.");
3342
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3343
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3344
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3345
- try {
3346
- await joinWorkspace(socket, workspaceId);
3347
- const snap = await loadDoc(socket, workspaceId, parsed.docId);
3348
- if (!snap.missing)
3349
- throw new Error(`Doc ${parsed.docId} not found.`);
3350
- const doc = new Y.Doc();
3351
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
3352
- const blocks = doc.getMap("blocks");
3353
- let totalMatches = 0;
3354
- const matchLog = [];
3355
- const matchAll = parsed.matchAll !== false;
3356
- for (const [blockId, raw] of blocks) {
3357
- if (!(raw instanceof Y.Map))
3358
- continue;
3359
- const flavour = raw.get("sys:flavour");
3360
- for (const [, val] of raw) {
3361
- if (!(val instanceof Y.Text))
3362
- continue;
3363
- const original = val.toString();
3364
- if (!original.includes(parsed.search))
3365
- continue;
3366
- const replaced = matchAll
3367
- ? original.split(parsed.search).join(parsed.replace)
3368
- : original.replace(parsed.search, parsed.replace);
3369
- const count = matchAll ? original.split(parsed.search).length - 1 : 1;
3370
- totalMatches += count;
3371
- matchLog.push({ blockId, flavour: flavour ?? "unknown", original, replaced });
3372
- if (!parsed.dryRun) {
3373
- const prevSV = Y.encodeStateVector(doc);
3374
- val.delete(0, val.length);
3375
- val.insert(0, replaced);
3376
- const delta = Y.encodeStateAsUpdate(doc, prevSV);
3377
- await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
3378
- }
3379
- }
3380
- }
3381
- return text({
3382
- docId: parsed.docId, search: parsed.search, replace: parsed.replace,
3383
- dryRun: parsed.dryRun ?? false, totalMatches, blocksAffected: matchLog.length, matches: matchLog,
3384
- });
3385
- }
3386
- finally {
3387
- socket.disconnect();
3388
- }
3389
- };
3390
- server.registerTool("find_and_replace", {
3391
- title: "Find and Replace in Document",
3392
- description: "Find and replace text across all Y.Text fields in a document (paragraphs, headings, titles). matchAll defaults to true. Use dryRun=true to preview before applying.",
3393
- inputSchema: {
3394
- workspaceId: z.string().optional(),
3395
- docId: z.string().describe("The doc to search in."),
3396
- search: z.string().min(1).describe("Text to find (must not be empty)."),
3397
- replace: z.string().describe("Replacement text."),
3398
- matchAll: z.boolean().optional().describe("Replace all occurrences (default: true)."),
3399
- dryRun: z.boolean().optional().describe("If true, only report matches without replacing (default: false)."),
3400
- },
3401
- }, findAndReplaceHandler);
3402
- // ─── get_docs_by_tag ────────────────────────────────────────────────────────
3403
- const getDocsByTagHandler = async (parsed) => {
3404
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
3405
- if (!workspaceId)
3406
- throw new Error("workspaceId is required.");
3407
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3408
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3409
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3410
- try {
3411
- await joinWorkspace(socket, workspaceId);
3412
- const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
3413
- if (!wsSnap.missing)
3414
- return text({ tag: parsed.tag, count: 0, docs: [] });
3415
- const wsDoc = new Y.Doc();
3416
- Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
3417
- const meta = wsDoc.getMap("meta");
3418
- const { byId, options } = getWorkspaceTagOptionMaps(meta);
3419
- const q = parsed.tag.toLowerCase();
3420
- const matchingTagIds = new Set(options.filter(o => o.value.toLowerCase().includes(q)).map(o => o.id));
3421
- if (matchingTagIds.size === 0) {
3422
- return text({
3423
- tag: parsed.tag,
3424
- count: 0,
3425
- docs: [],
3426
- availableTags: options.map(o => o.value),
3427
- });
3428
- }
3429
- const pages = getWorkspacePageEntries(meta);
3430
- const baseUrl = (process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '');
3431
- const matched = pages
3432
- .map(p => {
3433
- const rawTagIds = getStringArray(p.tagsArray);
3434
- return { p, rawTagIds };
3435
- })
3436
- .filter(({ rawTagIds }) => rawTagIds.some(tid => matchingTagIds.has(tid)))
3437
- .map(({ p, rawTagIds }) => ({
3438
- docId: p.id,
3439
- title: p.title ?? "Untitled",
3440
- tags: resolveTagLabels(rawTagIds, byId),
3441
- url: `${baseUrl}/workspace/${workspaceId}/${p.id}`,
3442
- }));
3443
- return text({ tag: parsed.tag, count: matched.length, docs: matched });
3444
- }
3445
- finally {
3446
- socket.disconnect();
3447
- }
3448
- };
3449
- server.registerTool("get_docs_by_tag", {
3450
- title: "Get Documents by Tag",
3451
- description: "Filter documents by tag name (case-insensitive substring match). Returns matching docs with their full tag list. If no match, also returns availableTags for discoverability.",
3452
- inputSchema: {
3453
- workspaceId: z.string().optional(),
3454
- tag: z.string().describe("Tag name to filter by (substring match, case-insensitive)."),
3455
- },
3456
- }, getDocsByTagHandler);
3457
4622
  // ─── list_workspace_tree ────────────────────────────────────────────────────
3458
4623
  const listWorkspaceTreeHandler = async (parsed) => {
3459
4624
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
@@ -3668,7 +4833,12 @@ export function registerDocTools(server, gql, defaults) {
3668
4833
  const delta = Y.encodeStateAsUpdate(doc, prevSV);
3669
4834
  await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
3670
4835
  }
3671
- return text({ updated: true, docId: parsed.docId, title: newTitle });
4836
+ return receipt("doc.update_title", {
4837
+ workspaceId,
4838
+ updated: true,
4839
+ docId: parsed.docId,
4840
+ title: newTitle,
4841
+ });
3672
4842
  }
3673
4843
  finally {
3674
4844
  socket.disconnect();
@@ -3683,212 +4853,344 @@ export function registerDocTools(server, gql, defaults) {
3683
4853
  title: z.string().describe("New title."),
3684
4854
  },
3685
4855
  }, updateDocTitleHandler);
3686
- // ─── get_doc_by_title ────────────────────────────────────────────────────────
3687
- const getDocByTitleHandler = async (parsed) => {
3688
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
3689
- if (!workspaceId)
3690
- throw new Error("workspaceId is required.");
4856
+ async function syncRawTagsToDoc(parsed) {
4857
+ if (parsed.tags.length === 0) {
4858
+ return;
4859
+ }
3691
4860
  const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3692
4861
  const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3693
4862
  const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3694
4863
  try {
3695
- await joinWorkspace(socket, workspaceId);
3696
- const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
3697
- if (!wsSnap.missing)
3698
- return text({ query: parsed.query, found: false, results: [] });
4864
+ await joinWorkspace(socket, parsed.workspaceId);
4865
+ const workspaceSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.workspaceId);
4866
+ if (!workspaceSnapshot.missing) {
4867
+ throw new Error(`Workspace root document not found for workspace ${parsed.workspaceId}`);
4868
+ }
3699
4869
  const wsDoc = new Y.Doc();
3700
- Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
3701
- const q = parsed.query.toLowerCase();
3702
- const limit = parsed.limit ?? 1;
3703
- const matches = getWorkspacePageEntries(wsDoc.getMap("meta"))
3704
- .filter(p => p.title && p.title.toLowerCase().includes(q)).slice(0, limit);
3705
- if (matches.length === 0)
3706
- return text({ query: parsed.query, found: false, results: [] });
3707
- const results = [];
3708
- for (const match of matches) {
3709
- const snap = await loadDoc(socket, workspaceId, match.id);
3710
- if (!snap.missing) {
3711
- results.push({ docId: match.id, title: match.title, found: false });
3712
- continue;
3713
- }
3714
- const doc = new Y.Doc();
3715
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
3716
- const collected = collectDocForMarkdown(doc, new Map());
3717
- const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
3718
- results.push({ docId: match.id, title: match.title, found: true, markdown: rendered.markdown,
3719
- url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${match.id}` });
4870
+ Y.applyUpdate(wsDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
4871
+ const wsPrevSV = Y.encodeStateVector(wsDoc);
4872
+ const wsMeta = wsDoc.getMap("meta");
4873
+ const page = getWorkspacePageEntries(wsMeta).find(entry => entry.id === parsed.docId);
4874
+ if (!page) {
4875
+ throw new Error(`docId ${parsed.docId} is not present in workspace ${parsed.workspaceId}`);
4876
+ }
4877
+ const pageTags = ensureTagArray(page.entry);
4878
+ pageTags.delete(0, pageTags.length);
4879
+ for (const tag of parsed.tags) {
4880
+ pageTags.push([tag]);
4881
+ }
4882
+ const wsDelta = Y.encodeStateAsUpdate(wsDoc, wsPrevSV);
4883
+ await pushDocUpdate(socket, parsed.workspaceId, parsed.workspaceId, Buffer.from(wsDelta).toString("base64"));
4884
+ const docSnapshot = await loadDoc(socket, parsed.workspaceId, parsed.docId);
4885
+ if (!docSnapshot.missing) {
4886
+ throw new Error(`Document ${parsed.docId} not found in workspace ${parsed.workspaceId}`);
3720
4887
  }
3721
- return text({ query: parsed.query, found: results.some(r => r.found), results });
4888
+ const doc = new Y.Doc();
4889
+ Y.applyUpdate(doc, Buffer.from(docSnapshot.missing, "base64"));
4890
+ const docPrevSV = Y.encodeStateVector(doc);
4891
+ const docMeta = doc.getMap("meta");
4892
+ const docTags = ensureTagArray(docMeta);
4893
+ docTags.delete(0, docTags.length);
4894
+ for (const tag of parsed.tags) {
4895
+ docTags.push([tag]);
4896
+ }
4897
+ const docDelta = Y.encodeStateAsUpdate(doc, docPrevSV);
4898
+ await pushDocUpdate(socket, parsed.workspaceId, parsed.docId, Buffer.from(docDelta).toString("base64"));
3722
4899
  }
3723
4900
  finally {
3724
4901
  socket.disconnect();
3725
4902
  }
3726
- };
3727
- server.registerTool("get_doc_by_title", {
3728
- title: "Get Document by Title",
3729
- description: "Find a document by title and return its content as markdown in a single call. Combines search_docs + export_doc_markdown. Returns the first match by default; use limit for multiple.",
3730
- inputSchema: {
3731
- workspaceId: z.string().optional(),
3732
- query: z.string().describe("Title search query (case-insensitive substring match)."),
3733
- limit: z.number().optional().describe("Max docs to return with content (default: 1)."),
3734
- },
3735
- }, getDocByTitleHandler);
3736
- // ─── list_backlinks ──────────────────────────────────────────────────────────
3737
- const listBacklinksHandler = async (parsed) => {
4903
+ }
4904
+ const inspectTemplateStructureHandler = async (parsed) => {
3738
4905
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
3739
- if (!workspaceId)
3740
- throw new Error("workspaceId is required.");
4906
+ if (!workspaceId) {
4907
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID.");
4908
+ }
3741
4909
  const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3742
4910
  const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3743
4911
  const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3744
4912
  try {
3745
4913
  await joinWorkspace(socket, workspaceId);
3746
- const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
3747
- if (!wsSnap.missing)
3748
- return text({ docId: parsed.docId, count: 0, backlinks: [] });
3749
- const wsDoc = new Y.Doc();
3750
- Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
3751
- const pages = getWorkspacePageEntries(wsDoc.getMap("meta"));
3752
- const titleById = new Map(pages.map(p => [p.id, p.title]));
3753
- const backlinks = [];
3754
- for (const page of pages) {
3755
- if (page.id === parsed.docId)
3756
- continue;
3757
- const snap = await loadDoc(socket, workspaceId, page.id);
3758
- if (!snap.missing)
3759
- continue;
3760
- const doc = new Y.Doc();
3761
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
3762
- const blocks = doc.getMap("blocks");
3763
- for (const [, raw] of blocks) {
3764
- if (!(raw instanceof Y.Map))
3765
- continue;
3766
- if (raw.get("sys:flavour") === "affine:embed-linked-doc" && raw.get("prop:pageId") === parsed.docId) {
3767
- backlinks.push({ docId: page.id, title: titleById.get(page.id) ?? null,
3768
- url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${page.id}` });
3769
- break;
3770
- }
3771
- }
4914
+ const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
4915
+ if (!workspaceSnapshot.missing) {
4916
+ throw new Error(`Workspace root document not found for workspace ${workspaceId}`);
3772
4917
  }
3773
- return text({ docId: parsed.docId, count: backlinks.length, backlinks });
4918
+ const workspaceDoc = new Y.Doc();
4919
+ Y.applyUpdate(workspaceDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
4920
+ const tagOptionsById = getWorkspaceTagOptionMaps(workspaceDoc.getMap("meta")).byId;
4921
+ const templateSnapshot = await loadDoc(socket, workspaceId, parsed.templateDocId);
4922
+ if (!templateSnapshot.missing) {
4923
+ throw new Error(`Template doc ${parsed.templateDocId} not found.`);
4924
+ }
4925
+ const templateDoc = new Y.Doc();
4926
+ Y.applyUpdate(templateDoc, Buffer.from(templateSnapshot.missing, "base64"));
4927
+ const meta = templateDoc.getMap("meta");
4928
+ const rawTags = getStringArray(getTagArray(meta));
4929
+ const resolvedTags = resolveTagLabels(rawTags, tagOptionsById);
4930
+ const supportIssues = [];
4931
+ scanNativeTemplateValue(meta, "meta", supportIssues, new WeakSet());
4932
+ scanNativeTemplateValue(templateDoc.getMap("blocks"), "blocks", supportIssues, new WeakSet());
4933
+ return text(summarizeNativeTemplateStructure(templateDoc, workspaceId, parsed.templateDocId, resolvedTags, supportIssues));
3774
4934
  }
3775
4935
  finally {
3776
4936
  socket.disconnect();
3777
4937
  }
3778
4938
  };
3779
- server.registerTool("list_backlinks", {
3780
- title: "List Document Backlinks",
3781
- description: "Find all documents that embed-link to a given doc (its parents/references in the sidebar). Scans all docs — may be slow on large workspaces.",
4939
+ server.registerTool("inspect_template_structure", {
4940
+ title: "Inspect Template Structure",
4941
+ description: "Inspect a template doc's native structure, tags, and fallback risk before instantiation.",
3782
4942
  inputSchema: {
3783
- workspaceId: z.string().optional(),
3784
- docId: z.string().describe("The doc to find backlinks for."),
4943
+ workspaceId: WorkspaceId.optional(),
4944
+ templateDocId: z.string().describe("The template doc to inspect."),
3785
4945
  },
3786
- }, listBacklinksHandler);
3787
- // ─── duplicate_doc ───────────────────────────────────────────────────────────
3788
- const duplicateDocHandler = async (parsed) => {
4946
+ }, inspectTemplateStructureHandler);
4947
+ const instantiateTemplateNativeHandler = async (parsed) => {
3789
4948
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
3790
- if (!workspaceId)
3791
- throw new Error("workspaceId is required.");
4949
+ if (!workspaceId) {
4950
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID.");
4951
+ }
4952
+ const allowFallback = parsed.allowFallback !== false;
4953
+ const preserveTags = parsed.preserveTags !== false;
3792
4954
  const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3793
4955
  const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3794
4956
  const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3795
4957
  try {
3796
4958
  await joinWorkspace(socket, workspaceId);
3797
- const snap = await loadDoc(socket, workspaceId, parsed.docId);
3798
- if (!snap.missing)
3799
- throw new Error(`Doc ${parsed.docId} not found.`);
3800
- const doc = new Y.Doc();
3801
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
3802
- const collected = collectDocForMarkdown(doc, new Map());
3803
- const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
3804
- const newTitle = (parsed.title ?? `${collected.title || "Untitled"} (copy)`).trim();
3805
- socket.disconnect();
3806
- const r = await createDocFromMarkdownHandler({ workspaceId, title: newTitle, markdown: rendered.markdown });
3807
- const created = JSON.parse(r.content[0].text);
3808
- let linkedToParent = false;
3809
- if (parsed.parentDocId && created.docId) {
3810
- try {
3811
- await appendBlockInternal({ workspaceId, docId: parsed.parentDocId, type: "embed_linked_doc", pageId: created.docId });
3812
- linkedToParent = true;
3813
- }
3814
- catch { /* non-fatal */ }
4959
+ const workspaceSnapshot = await loadDoc(socket, workspaceId, workspaceId);
4960
+ if (!workspaceSnapshot.missing) {
4961
+ throw new Error(`Workspace root document not found for workspace ${workspaceId}`);
3815
4962
  }
3816
- return text({ sourceDocId: parsed.docId, docId: created.docId, title: created.title, linkedToParent, warnings: created.warnings ?? [] });
3817
- }
3818
- catch (err) {
3819
- try {
3820
- socket.disconnect();
4963
+ const workspaceDoc = new Y.Doc();
4964
+ Y.applyUpdate(workspaceDoc, Buffer.from(workspaceSnapshot.missing, "base64"));
4965
+ const workspaceMeta = workspaceDoc.getMap("meta");
4966
+ const tagOptionById = getWorkspaceTagOptionMaps(workspaceMeta).byId;
4967
+ const sourcePage = getWorkspacePageEntries(workspaceMeta).find(entry => entry.id === parsed.templateDocId);
4968
+ if (!sourcePage) {
4969
+ throw new Error(`Template doc ${parsed.templateDocId} was not found in workspace ${workspaceId}.`);
3821
4970
  }
3822
- catch { /* already disconnected */ }
3823
- throw err;
3824
- }
3825
- };
3826
- server.registerTool("duplicate_doc", {
3827
- title: "Duplicate Document",
3828
- description: "Clone a document by copying its markdown content into a new doc. Optionally set a new title and/or parentDocId to place it in the sidebar.",
3829
- inputSchema: {
3830
- workspaceId: z.string().optional(),
3831
- docId: z.string().describe("The source doc to duplicate."),
3832
- title: z.string().optional().describe("Title for the new doc. Defaults to '<original title> (copy)'."),
3833
- parentDocId: z.string().optional().describe("Parent doc to link the new doc under in the sidebar."),
3834
- },
3835
- }, duplicateDocHandler);
3836
- // ─── create_doc_from_template ───────────────────────────────────────────────
3837
- const createDocFromTemplateHandler = async (parsed) => {
3838
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
3839
- if (!workspaceId)
3840
- throw new Error("workspaceId is required.");
3841
- const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
3842
- const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
3843
- const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
3844
- try {
3845
- await joinWorkspace(socket, workspaceId);
3846
- const snap = await loadDoc(socket, workspaceId, parsed.templateDocId);
3847
- if (!snap.missing)
4971
+ const templateSnapshot = await loadDoc(socket, workspaceId, parsed.templateDocId);
4972
+ if (!templateSnapshot.missing) {
3848
4973
  throw new Error(`Template doc ${parsed.templateDocId} not found.`);
3849
- const doc = new Y.Doc();
3850
- Y.applyUpdate(doc, Buffer.from(snap.missing, "base64"));
3851
- const collected = collectDocForMarkdown(doc, new Map());
3852
- const rendered = renderBlocksToMarkdown({ rootBlockIds: collected.rootBlockIds, blocksById: collected.blocksById });
3853
- let markdown = rendered.markdown;
3854
- const vars = parsed.variables ?? {};
3855
- for (const [key, value] of Object.entries(vars)) {
3856
- const pattern = new RegExp(`\\{\\{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\}}`, "g");
3857
- markdown = markdown.replace(pattern, value);
3858
4974
  }
3859
- const unfilled = [...markdown.matchAll(/\{\{\s*[\w.-]+\s*\}\}/g)].map(match => match[0]);
3860
- socket.disconnect();
3861
- const result = await createDocFromMarkdownHandler({ workspaceId, title: parsed.title, markdown });
3862
- const created = JSON.parse(result.content[0].text);
4975
+ const templateDoc = new Y.Doc();
4976
+ Y.applyUpdate(templateDoc, Buffer.from(templateSnapshot.missing, "base64"));
4977
+ const templateMeta = templateDoc.getMap("meta");
4978
+ const templateBlocks = templateDoc.getMap("blocks");
4979
+ const rawTags = getStringArray(sourcePage.tagsArray);
4980
+ const resolvedTags = resolveTagLabels(rawTags, tagOptionById);
4981
+ const supportIssues = [];
4982
+ scanNativeTemplateValue(templateMeta, "meta", supportIssues, new WeakSet());
4983
+ scanNativeTemplateValue(templateBlocks, "blocks", supportIssues, new WeakSet());
4984
+ const nativeSummary = summarizeNativeTemplateStructure(templateDoc, workspaceId, parsed.templateDocId, resolvedTags, supportIssues);
4985
+ const sourceTitle = nativeSummary.title || sourcePage.title || "Untitled";
4986
+ const targetTitle = (parsed.title ?? sourceTitle).trim() || sourceTitle;
4987
+ if (!nativeSummary.nativeCloneSupported) {
4988
+ if (!allowFallback) {
4989
+ throw new Error(`Native template instantiation is not supported: ${nativeSummary.fallbackReasons.join(" | ")}`);
4990
+ }
4991
+ const created = await createDocFromTemplateCore({
4992
+ workspaceId,
4993
+ templateDocId: parsed.templateDocId,
4994
+ title: targetTitle,
4995
+ variables: parsed.variables,
4996
+ parentDocId: parsed.parentDocId,
4997
+ });
4998
+ return text({
4999
+ ...created,
5000
+ mode: "markdown_fallback",
5001
+ nativeCloneSupported: false,
5002
+ warnings: mergeWarnings(created.warnings ?? [], nativeSummary.fallbackReasons, ["Native template instantiation fell back to markdown materialization."]),
5003
+ });
5004
+ }
5005
+ const created = await createDocInternal({
5006
+ workspaceId,
5007
+ title: targetTitle,
5008
+ });
5009
+ const targetSnapshot = await loadDoc(socket, workspaceId, created.docId);
5010
+ if (!targetSnapshot.missing) {
5011
+ throw new Error(`Created doc ${created.docId} was not found for native template instantiation.`);
5012
+ }
5013
+ const targetDoc = new Y.Doc();
5014
+ Y.applyUpdate(targetDoc, Buffer.from(targetSnapshot.missing, "base64"));
5015
+ const prevSV = Y.encodeStateVector(targetDoc);
5016
+ const targetMeta = targetDoc.getMap("meta");
5017
+ const targetBlocks = targetDoc.getMap("blocks");
5018
+ const blockIdMap = new Map();
5019
+ for (const [sourceBlockId] of templateBlocks) {
5020
+ blockIdMap.set(String(sourceBlockId), generateId());
5021
+ }
5022
+ const cloneContext = {
5023
+ sourceDocId: parsed.templateDocId,
5024
+ targetDocId: created.docId,
5025
+ blockIdMap,
5026
+ variables: parsed.variables ?? {},
5027
+ unresolvedVariables: new Set(),
5028
+ replacedVariableCount: 0,
5029
+ };
5030
+ for (const [existingBlockId] of targetBlocks) {
5031
+ targetBlocks.delete(String(existingBlockId));
5032
+ }
5033
+ for (const [sourceBlockId, rawBlock] of templateBlocks) {
5034
+ if (!(rawBlock instanceof Y.Map)) {
5035
+ continue;
5036
+ }
5037
+ const nextBlockId = blockIdMap.get(String(sourceBlockId));
5038
+ const cloned = cloneNativeTemplateValue(rawBlock, cloneContext, `blocks.${String(sourceBlockId)}`);
5039
+ cloned.set("sys:id", nextBlockId);
5040
+ targetBlocks.set(nextBlockId, cloned);
5041
+ }
5042
+ targetMeta.set("id", created.docId);
5043
+ targetMeta.set("title", targetTitle);
5044
+ if (preserveTags) {
5045
+ const targetMetaTags = ensureTagArray(targetMeta);
5046
+ targetMetaTags.delete(0, targetMetaTags.length);
5047
+ for (const tag of rawTags) {
5048
+ targetMetaTags.push([tag]);
5049
+ }
5050
+ }
5051
+ const pageId = findBlockIdByFlavour(targetBlocks, "affine:page");
5052
+ if (pageId) {
5053
+ const pageBlock = findBlockById(targetBlocks, pageId);
5054
+ if (pageBlock) {
5055
+ pageBlock.set("prop:title", makeText(targetTitle));
5056
+ }
5057
+ }
5058
+ const delta = Y.encodeStateAsUpdate(targetDoc, prevSV);
5059
+ await pushDocUpdate(socket, workspaceId, created.docId, Buffer.from(delta).toString("base64"));
5060
+ if (preserveTags && rawTags.length > 0) {
5061
+ await syncRawTagsToDoc({
5062
+ workspaceId,
5063
+ docId: created.docId,
5064
+ tags: rawTags,
5065
+ });
5066
+ }
3863
5067
  let linkedToParent = false;
3864
- if (parsed.parentDocId && created.docId) {
5068
+ const warnings = [];
5069
+ if (parsed.parentDocId) {
3865
5070
  try {
3866
- await appendBlockInternal({ workspaceId, docId: parsed.parentDocId, type: "embed_linked_doc", pageId: created.docId });
5071
+ await appendBlockInternal({
5072
+ workspaceId,
5073
+ docId: parsed.parentDocId,
5074
+ type: "embed_linked_doc",
5075
+ pageId: created.docId,
5076
+ });
3867
5077
  linkedToParent = true;
3868
5078
  }
3869
- catch { /* non-fatal */ }
5079
+ catch (err) {
5080
+ warnings.push(`Doc created but could not be linked to parent "${parsed.parentDocId}": ${err?.message ?? "unknown error"}`);
5081
+ }
3870
5082
  }
3871
- return text({ ...created, sourceTemplateDocId: parsed.templateDocId, linkedToParent, unfilledVariables: unfilled });
5083
+ return text({
5084
+ workspaceId,
5085
+ sourceTemplateDocId: parsed.templateDocId,
5086
+ docId: created.docId,
5087
+ title: targetTitle,
5088
+ mode: "native",
5089
+ nativeCloneSupported: true,
5090
+ linkedToParent,
5091
+ preservedTags: preserveTags ? resolvedTags : [],
5092
+ replacedVariableCount: cloneContext.replacedVariableCount,
5093
+ unresolvedVariables: Array.from(cloneContext.unresolvedVariables),
5094
+ warnings,
5095
+ blockCount: nativeSummary.blockCount,
5096
+ rootBlockIds: nativeSummary.rootBlockIds.map(blockId => blockIdMap.get(blockId) ?? blockId),
5097
+ });
3872
5098
  }
3873
- catch (err) {
3874
- try {
3875
- socket.disconnect();
3876
- }
3877
- catch { /* already disconnected */ }
3878
- throw err;
5099
+ finally {
5100
+ socket.disconnect();
3879
5101
  }
3880
5102
  };
3881
- server.registerTool("create_doc_from_template", {
3882
- title: "Create Document from Template",
3883
- description: "Clone a template doc and substitute {{variable}} placeholders. Returns a warning for any unfilled variables. Optionally link to a parent doc in the sidebar.",
5103
+ server.registerTool("instantiate_template_native", {
5104
+ title: "Instantiate Template Natively",
5105
+ description: "Instantiate a template using native AFFiNE block cloning when supported, falling back to markdown materialization only when necessary.",
3884
5106
  inputSchema: {
3885
- workspaceId: z.string().optional(),
3886
- templateDocId: z.string().describe("The template doc to clone from."),
3887
- title: z.string().describe("Title for the new doc."),
3888
- variables: z.record(z.string(), z.string()).optional().describe("Key-value map of {{variable}} substitutions."),
3889
- parentDocId: z.string().optional().describe("Parent doc to link the new doc under in the sidebar."),
5107
+ workspaceId: WorkspaceId.optional(),
5108
+ templateDocId: z.string().describe("The template doc to instantiate."),
5109
+ title: z.string().optional().describe("Optional title for the new document. Defaults to the template title."),
5110
+ variables: z.record(z.string(), z.string()).optional().describe("Key-value map of {{variable}} substitutions applied during cloning."),
5111
+ parentDocId: z.string().optional().describe("Optional parent doc to link the instantiated doc under in the sidebar."),
5112
+ allowFallback: z.boolean().optional().describe("If false, fail instead of falling back to markdown materialization when native cloning is unsupported."),
5113
+ preserveTags: z.boolean().optional().describe("If true (default), copy the template's tags onto the instantiated doc."),
3890
5114
  },
3891
- }, createDocFromTemplateHandler);
5115
+ }, instantiateTemplateNativeHandler);
5116
+ function buildDatabaseIntentPreset(intent) {
5117
+ switch (intent) {
5118
+ case "task_board":
5119
+ return {
5120
+ title: "Task Board",
5121
+ viewName: "Task Board",
5122
+ statusOptions: ["Todo", "In Progress", "Blocked", "Done"],
5123
+ extraColumns: [
5124
+ { name: "Type", type: "select", options: ["Task", "Bug", "Chore"], width: 140 },
5125
+ { name: "Priority", type: "select", options: ["P0", "P1", "P2", "P3"], width: 120 },
5126
+ { name: "Owner", type: "rich-text", width: 180 },
5127
+ { name: "Due Date", type: "date", width: 160 },
5128
+ ],
5129
+ starterRows: [
5130
+ {
5131
+ title: "Define the scope",
5132
+ Status: "Todo",
5133
+ Type: "Task",
5134
+ Priority: "P1",
5135
+ Owner: "Product",
5136
+ },
5137
+ {
5138
+ title: "Build the first pass",
5139
+ Status: "In Progress",
5140
+ Type: "Task",
5141
+ Priority: "P1",
5142
+ Owner: "Engineering",
5143
+ },
5144
+ {
5145
+ title: "Review and ship",
5146
+ Status: "Done",
5147
+ Type: "Chore",
5148
+ Priority: "P2",
5149
+ Owner: "Delivery",
5150
+ },
5151
+ ],
5152
+ };
5153
+ case "issue_tracker":
5154
+ return {
5155
+ title: "Issue Tracker",
5156
+ viewName: "Issue Tracker",
5157
+ statusOptions: ["Open", "In Progress", "In Review", "Blocked", "Resolved", "Closed"],
5158
+ extraColumns: [
5159
+ { name: "Type", type: "select", options: ["Bug", "Feature", "Task", "Incident"], width: 140 },
5160
+ { name: "Priority", type: "select", options: ["P0", "P1", "P2", "P3"], width: 120 },
5161
+ { name: "Assignee", type: "rich-text", width: 180 },
5162
+ { name: "Due Date", type: "date", width: 160 },
5163
+ ],
5164
+ starterRows: [
5165
+ {
5166
+ title: "Document reproduction steps",
5167
+ Status: "Open",
5168
+ Type: "Bug",
5169
+ Priority: "P0",
5170
+ Assignee: "Unassigned",
5171
+ },
5172
+ {
5173
+ title: "Fix the regression",
5174
+ Status: "In Progress",
5175
+ Type: "Bug",
5176
+ Priority: "P1",
5177
+ Assignee: "Engineering",
5178
+ },
5179
+ {
5180
+ title: "Verify the release candidate",
5181
+ Status: "Resolved",
5182
+ Type: "Task",
5183
+ Priority: "P2",
5184
+ Assignee: "QA",
5185
+ },
5186
+ ],
5187
+ };
5188
+ default: {
5189
+ const exhaustiveCheck = intent;
5190
+ throw new Error(`Unsupported database intent '${exhaustiveCheck}'`);
5191
+ }
5192
+ }
5193
+ }
3892
5194
  /** Read column definitions including select options from a database block */
3893
5195
  function readColumnDefs(dbBlock) {
3894
5196
  const columnsRaw = dbBlock.get("prop:columns");
@@ -4043,6 +5345,36 @@ export function registerDocTools(server, gql, defaults) {
4043
5345
  cellsMap.set(rowBlockId, rowCells);
4044
5346
  return rowCells;
4045
5347
  }
5348
+ function addDatabaseRowToBlock(parsed) {
5349
+ const rowBlockId = generateId();
5350
+ const rowBlock = new Y.Map();
5351
+ setSysFields(rowBlock, rowBlockId, "affine:paragraph");
5352
+ rowBlock.set("sys:parent", parsed.databaseBlockId);
5353
+ rowBlock.set("sys:children", new Y.Array());
5354
+ rowBlock.set("prop:type", "text");
5355
+ if (parsed.linkedDocId) {
5356
+ rowBlock.set("prop:text", makeLinkedDocText(parsed.linkedDocId));
5357
+ }
5358
+ else {
5359
+ const titleValue = resolveDatabaseTitleValue(parsed.cells, parsed.lookup);
5360
+ rowBlock.set("prop:text", makeText(String(titleValue)));
5361
+ }
5362
+ parsed.blocks.set(rowBlockId, rowBlock);
5363
+ const dbChildren = ensureChildrenArray(parsed.dbBlock);
5364
+ dbChildren.push([rowBlockId]);
5365
+ const rowCells = ensureDatabaseRowCells(parsed.cellsMap, rowBlockId);
5366
+ for (const [key, value] of Object.entries(parsed.cells)) {
5367
+ const col = findDatabaseColumn(key, parsed.lookup);
5368
+ if (!col) {
5369
+ if (isTitleAliasKey(key)) {
5370
+ continue;
5371
+ }
5372
+ throw new Error(`Column '${key}' not found. Available columns: ${availableDatabaseColumns(parsed.lookup)}`);
5373
+ }
5374
+ writeDatabaseCellValue(rowCells, col, value, true);
5375
+ }
5376
+ return rowBlockId;
5377
+ }
4046
5378
  function getDatabaseRowBlock(blocks, dbBlock, databaseBlockId, rowBlockId) {
4047
5379
  const rowBlock = findBlockById(blocks, rowBlockId);
4048
5380
  if (!rowBlock) {
@@ -4465,56 +5797,6 @@ export function registerDocTools(server, gql, defaults) {
4465
5797
  databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
4466
5798
  },
4467
5799
  }, readDatabaseColumnsHandler);
4468
- const updateDatabaseCellHandler = async (parsed) => {
4469
- const workspaceId = parsed.workspaceId || defaults.workspaceId;
4470
- if (!workspaceId)
4471
- throw new Error("workspaceId is required");
4472
- const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
4473
- try {
4474
- const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
4475
- const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
4476
- const col = findDatabaseColumn(parsed.column, ctx);
4477
- if (!col) {
4478
- if (!isTitleAliasKey(parsed.column)) {
4479
- throw new Error(`Column '${parsed.column}' not found. Available columns: ${availableDatabaseColumns(ctx)}`);
4480
- }
4481
- }
4482
- else {
4483
- writeDatabaseCellValue(rowCells, col, parsed.value, parsed.createOption ?? true);
4484
- }
4485
- if (parsed.linkedDocId) {
4486
- rowBlock.set("prop:text", makeLinkedDocText(parsed.linkedDocId));
4487
- }
4488
- else if (isTitleAliasKey(parsed.column) || (col && (col.type === "title" || isTitleAliasKey(col.name)))) {
4489
- rowBlock.set("prop:text", makeText(String(parsed.value ?? "")));
4490
- }
4491
- const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
4492
- await pushDocUpdate(ctx.socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
4493
- return text({
4494
- updated: true,
4495
- rowBlockId: parsed.rowBlockId,
4496
- column: parsed.column,
4497
- value: parsed.value ?? null,
4498
- });
4499
- }
4500
- finally {
4501
- ctx.socket.disconnect();
4502
- }
4503
- };
4504
- server.registerTool("update_database_cell", {
4505
- title: "Update Database Cell",
4506
- description: "Update a single cell on an existing AFFiNE database row. Use `title` to update the row title shown in Kanban card headers.",
4507
- inputSchema: {
4508
- workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
4509
- docId: DocId.describe("Document ID containing the database"),
4510
- databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
4511
- rowBlockId: z.string().min(1).describe("Row paragraph block ID"),
4512
- column: z.string().min(1).describe("Column name or ID. Use `title` for the built-in row title."),
4513
- value: z.unknown().describe("New cell value"),
4514
- createOption: z.boolean().optional().describe("For select and multi-select columns, create the option label if it does not exist (default true)"),
4515
- linkedDocId: z.string().optional().describe("Link this row to an existing doc by ID. Replaces any existing title with a linked doc reference."),
4516
- },
4517
- }, updateDatabaseCellHandler);
4518
5800
  const updateDatabaseRowHandler = async (parsed) => {
4519
5801
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
4520
5802
  if (!workspaceId)
@@ -4569,6 +5851,109 @@ export function registerDocTools(server, gql, defaults) {
4569
5851
  linkedDocId: z.string().optional().describe("Link this row to an existing doc by ID. The row will open the linked doc in center peek when clicked."),
4570
5852
  },
4571
5853
  }, updateDatabaseRowHandler);
5854
+ const composeDatabaseFromIntentCore = async (parsed) => {
5855
+ const workspaceId = parsed.workspaceId || defaults.workspaceId;
5856
+ if (!workspaceId) {
5857
+ throw new Error("workspaceId is required");
5858
+ }
5859
+ const preset = buildDatabaseIntentPreset(parsed.intent);
5860
+ const title = (parsed.title ?? preset.title).trim() || preset.title;
5861
+ const starterRows = Array.isArray(parsed.seedRows) ? parsed.seedRows : preset.starterRows;
5862
+ const creation = await appendBlockInternal({
5863
+ workspaceId,
5864
+ docId: parsed.docId,
5865
+ type: "data_view",
5866
+ viewMode: "kanban",
5867
+ text: title,
5868
+ placement: parsed.placement,
5869
+ });
5870
+ const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, creation.blockId);
5871
+ try {
5872
+ const warnings = [];
5873
+ const statusLookup = buildDatabaseColumnLookup(readColumnDefs(ctx.dbBlock));
5874
+ const statusColumn = statusLookup.colByNameLower.get("status");
5875
+ if (statusColumn?.raw instanceof Y.Map) {
5876
+ replaceSelectColumnOptions(statusColumn.raw, preset.statusOptions);
5877
+ }
5878
+ else {
5879
+ warnings.push("Status column was not found after database creation.");
5880
+ }
5881
+ const addedColumnIds = [];
5882
+ for (const columnSpec of preset.extraColumns) {
5883
+ addedColumnIds.push(addDatabaseColumnToBlock(ctx.dbBlock, columnSpec));
5884
+ }
5885
+ const viewEntries = ctx.dbBlock.get("prop:views");
5886
+ if (viewEntries instanceof Y.Array) {
5887
+ const primaryView = viewEntries.get(0);
5888
+ if (primaryView instanceof Y.Map) {
5889
+ primaryView.set("name", preset.viewName);
5890
+ }
5891
+ }
5892
+ const rowBlockIds = [];
5893
+ for (const rowInput of starterRows) {
5894
+ rowBlockIds.push(addDatabaseRowToBlock({
5895
+ blocks: ctx.blocks,
5896
+ dbBlock: ctx.dbBlock,
5897
+ cellsMap: ctx.cellsMap,
5898
+ databaseBlockId: creation.blockId,
5899
+ lookup: buildDatabaseColumnLookup(readColumnDefs(ctx.dbBlock)),
5900
+ cells: rowInput,
5901
+ }));
5902
+ }
5903
+ const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
5904
+ await pushDocUpdate(ctx.socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
5905
+ const finalLookup = buildDatabaseColumnLookup(readColumnDefs(ctx.dbBlock));
5906
+ const finalViews = readDatabaseViewDefs(ctx.dbBlock, finalLookup);
5907
+ const columnSummary = finalLookup.columnDefs.map(column => ({
5908
+ id: column.id,
5909
+ name: column.name || null,
5910
+ type: column.type,
5911
+ options: column.options,
5912
+ }));
5913
+ return {
5914
+ workspaceId,
5915
+ docId: parsed.docId,
5916
+ intent: parsed.intent,
5917
+ title,
5918
+ databaseBlockId: creation.blockId,
5919
+ primaryViewId: finalViews[0]?.id || null,
5920
+ viewIds: finalViews.map(view => view.id),
5921
+ columnIds: finalLookup.columnDefs.map(column => column.id),
5922
+ rowBlockIds: getDatabaseRowIds(ctx.dbBlock),
5923
+ columns: columnSummary,
5924
+ views: finalViews,
5925
+ warnings: mergeWarnings(warnings),
5926
+ lossy: false,
5927
+ stats: {
5928
+ columnCount: columnSummary.length,
5929
+ viewCount: finalViews.length,
5930
+ rowCount: getDatabaseRowIds(ctx.dbBlock).length,
5931
+ addedColumnCount: addedColumnIds.length,
5932
+ seededRowCount: rowBlockIds.length,
5933
+ },
5934
+ };
5935
+ }
5936
+ finally {
5937
+ ctx.socket.disconnect();
5938
+ }
5939
+ };
5940
+ server.registerTool("compose_database_from_intent", {
5941
+ title: "Compose Database From Intent",
5942
+ description: "Create a useful AFFiNE database/data-view from declarative intent. Supports task_board and issue_tracker presets with starter schema, kanban view, and optional starter rows.",
5943
+ inputSchema: {
5944
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
5945
+ docId: DocId.describe("Document ID containing the database"),
5946
+ intent: DatabaseIntent.describe("Declarative database intent to compose."),
5947
+ title: z.string().optional().describe("Optional database title. Defaults to the intent preset title."),
5948
+ seedRows: z.array(z.record(z.unknown())).optional().describe("Optional starter rows. If omitted, the preset starter rows are used."),
5949
+ placement: z.object({
5950
+ parentId: z.string().optional(),
5951
+ afterBlockId: z.string().optional(),
5952
+ beforeBlockId: z.string().optional(),
5953
+ index: z.number().int().min(0).optional(),
5954
+ }).optional().describe("Optional insertion target/position"),
5955
+ },
5956
+ }, async (parsed) => text(await composeDatabaseFromIntentCore(parsed)));
4572
5957
  // ADD DATABASE COLUMN
4573
5958
  const addDatabaseColumnHandler = async (parsed) => {
4574
5959
  const workspaceId = parsed.workspaceId || defaults.workspaceId;
@@ -4663,4 +6048,1256 @@ export function registerDocTools(server, gql, defaults) {
4663
6048
  width: z.number().optional().describe("Column width in pixels (default 200)"),
4664
6049
  },
4665
6050
  }, addDatabaseColumnHandler);
6051
+ function resolveSurfaceNewlines(s) {
6052
+ return s.replace(/\\n/g, "\n");
6053
+ }
6054
+ function getSurfaceElementsValueMap(blocks, options) {
6055
+ let surfaceId = findBlockIdByFlavour(blocks, "affine:surface");
6056
+ if (!surfaceId) {
6057
+ if (!options.create)
6058
+ return null;
6059
+ surfaceId = ensureSurfaceBlock(blocks);
6060
+ }
6061
+ const surface = blocks.get(surfaceId);
6062
+ let elements = surface.get("prop:elements");
6063
+ if (!(elements instanceof Y.Map)) {
6064
+ if (!options.create)
6065
+ return null;
6066
+ elements = new Y.Map();
6067
+ elements.set("type", "$blocksuite:internal:native$");
6068
+ elements.set("value", new Y.Map());
6069
+ surface.set("prop:elements", elements);
6070
+ }
6071
+ let value = elements.get("value");
6072
+ if (!(value instanceof Y.Map)) {
6073
+ if (!options.create)
6074
+ return null;
6075
+ value = new Y.Map();
6076
+ elements.set("value", value);
6077
+ }
6078
+ return { surfaceId, value };
6079
+ }
6080
+ function serializeSurfaceElement(elementId, el) {
6081
+ const out = { id: elementId };
6082
+ for (const [k, v] of el.entries()) {
6083
+ if (v instanceof Y.Text) {
6084
+ out[k] = v.toString();
6085
+ }
6086
+ else if (v instanceof Y.Map) {
6087
+ out[k] = v.toJSON();
6088
+ }
6089
+ else if (v instanceof Y.Array) {
6090
+ out[k] = v.toArray();
6091
+ }
6092
+ else {
6093
+ out[k] = v;
6094
+ }
6095
+ }
6096
+ const xywh = parseXywhString(out.xywh);
6097
+ if (xywh)
6098
+ out.bounds = xywh;
6099
+ return out;
6100
+ }
6101
+ function buildShapeElementData(elementId, seed, index, input) {
6102
+ const x = input.x ?? 0;
6103
+ const y = input.y ?? 0;
6104
+ const w = input.width ?? 100;
6105
+ const h = input.height ?? 100;
6106
+ const data = {
6107
+ type: "shape",
6108
+ id: elementId,
6109
+ index,
6110
+ seed,
6111
+ xywh: formatXywhString(x, y, w, h),
6112
+ rotate: 0,
6113
+ shapeType: input.shapeType ?? "rect",
6114
+ shapeStyle: "General",
6115
+ radius: input.radius ?? 0,
6116
+ filled: input.filled ?? true,
6117
+ fillColor: input.fillColor ?? "--affine-palette-shape-yellow",
6118
+ strokeWidth: input.strokeWidth ?? 2,
6119
+ strokeColor: input.strokeColor ?? "--affine-palette-line-yellow",
6120
+ strokeStyle: input.strokeStyle ?? "solid",
6121
+ roughness: 1.4,
6122
+ // Fixed #000000 matches BlockSuite's shape tool: shape fills don't
6123
+ // theme-adapt, so label color stays pinned too. Override for dark fills.
6124
+ color: input.color ?? "#000000",
6125
+ fontFamily: "blocksuite:surface:Inter",
6126
+ fontSize: input.fontSize ?? 20,
6127
+ fontStyle: "normal",
6128
+ fontWeight: input.fontWeight ?? "600",
6129
+ textAlign: "center",
6130
+ textHorizontalAlign: "center",
6131
+ textVerticalAlign: "center",
6132
+ textResizing: 1,
6133
+ maxWidth: false,
6134
+ padding: [10, 20],
6135
+ shadow: null,
6136
+ };
6137
+ if (input.text) {
6138
+ const yText = new Y.Text();
6139
+ yText.insert(0, resolveSurfaceNewlines(input.text));
6140
+ data.text = yText;
6141
+ }
6142
+ return data;
6143
+ }
6144
+ function resolveConnectorEndpointBoundAsBound(surfaceValueMap, blocks, endpointId) {
6145
+ if (!endpointId)
6146
+ return null;
6147
+ const surfaceEl = surfaceValueMap.get(endpointId);
6148
+ if (surfaceEl instanceof Y.Map) {
6149
+ const xywh = parseXywhString(surfaceEl.get("xywh"));
6150
+ if (xywh)
6151
+ return { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height };
6152
+ }
6153
+ const block = blocks.get(endpointId);
6154
+ if (block instanceof Y.Map) {
6155
+ const xywh = parseXywhString(block.get("prop:xywh"));
6156
+ if (xywh)
6157
+ return { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height };
6158
+ }
6159
+ return null;
6160
+ }
6161
+ function resolveChildBound(surfaceValueMap, blocks, id) {
6162
+ const surfaceEl = surfaceValueMap.get(id);
6163
+ if (surfaceEl instanceof Y.Map) {
6164
+ const xywh = parseXywhString(surfaceEl.get("xywh"));
6165
+ return {
6166
+ kind: "surface",
6167
+ bound: xywh ? { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height } : null,
6168
+ };
6169
+ }
6170
+ const block = blocks.get(id);
6171
+ if (block instanceof Y.Map) {
6172
+ const xywh = parseXywhString(block.get("prop:xywh"));
6173
+ return {
6174
+ kind: "block",
6175
+ bound: xywh ? { x: xywh.x, y: xywh.y, w: xywh.width, h: xywh.height } : null,
6176
+ };
6177
+ }
6178
+ return { kind: "missing", bound: null };
6179
+ }
6180
+ function resolveConnectorEndpointCenter(surfaceValueMap, blocks, endpointId, endpointPosition) {
6181
+ if (endpointPosition && Array.isArray(endpointPosition) && endpointPosition.length === 2) {
6182
+ return { x: endpointPosition[0], y: endpointPosition[1] };
6183
+ }
6184
+ if (!endpointId)
6185
+ return null;
6186
+ const surfaceEl = surfaceValueMap.get(endpointId);
6187
+ if (surfaceEl instanceof Y.Map) {
6188
+ const xywh = parseXywhString(surfaceEl.get("xywh"));
6189
+ if (xywh)
6190
+ return { x: xywh.x + xywh.width / 2, y: xywh.y + xywh.height / 2 };
6191
+ }
6192
+ const block = blocks.get(endpointId);
6193
+ if (block instanceof Y.Map) {
6194
+ const xywh = parseXywhString(block.get("prop:xywh"));
6195
+ if (xywh)
6196
+ return { x: xywh.x + xywh.width / 2, y: xywh.y + xywh.height / 2 };
6197
+ }
6198
+ return null;
6199
+ }
6200
+ function buildConnectorElementData(elementId, seed, index, input) {
6201
+ const source = {};
6202
+ if (input.sourceId) {
6203
+ source.id = input.sourceId;
6204
+ if (input.sourcePosition)
6205
+ source.position = input.sourcePosition;
6206
+ }
6207
+ else if (input.sourcePosition) {
6208
+ source.position = input.sourcePosition;
6209
+ }
6210
+ const target = {};
6211
+ if (input.targetId) {
6212
+ target.id = input.targetId;
6213
+ if (input.targetPosition)
6214
+ target.position = input.targetPosition;
6215
+ }
6216
+ else if (input.targetPosition) {
6217
+ target.position = input.targetPosition;
6218
+ }
6219
+ const data = {
6220
+ type: "connector",
6221
+ id: elementId,
6222
+ index,
6223
+ seed,
6224
+ mode: input.mode ?? 2,
6225
+ // Theme-adaptive token so connectors stay legible in dark mode.
6226
+ stroke: input.stroke ?? "--affine-text-primary-color",
6227
+ strokeWidth: input.strokeWidth ?? 2,
6228
+ strokeStyle: input.strokeStyle ?? "solid",
6229
+ roughness: 1.4,
6230
+ frontEndpointStyle: input.frontEndpointStyle ?? "None",
6231
+ rearEndpointStyle: input.rearEndpointStyle ?? "Arrow",
6232
+ source,
6233
+ target,
6234
+ labelDisplay: true,
6235
+ labelOffset: { distance: 0.5, anchor: "center" },
6236
+ labelStyle: {
6237
+ color: "--affine-text-primary-color",
6238
+ fontFamily: "blocksuite:surface:Inter",
6239
+ fontSize: 16,
6240
+ fontStyle: "normal",
6241
+ fontWeight: "400",
6242
+ textAlign: "center",
6243
+ },
6244
+ labelConstraints: { hasMaxWidth: true, maxWidth: 280 },
6245
+ };
6246
+ if (input.label) {
6247
+ const yText = new Y.Text();
6248
+ yText.insert(0, resolveSurfaceNewlines(input.label));
6249
+ data.text = yText;
6250
+ }
6251
+ return data;
6252
+ }
6253
+ function buildTextElementData(elementId, seed, index, input) {
6254
+ const x = input.x ?? 0;
6255
+ const y = input.y ?? 0;
6256
+ const w = input.width ?? 200;
6257
+ const h = input.height ?? 30;
6258
+ const yText = new Y.Text();
6259
+ if (input.text)
6260
+ yText.insert(0, resolveSurfaceNewlines(input.text));
6261
+ return {
6262
+ type: "text",
6263
+ id: elementId,
6264
+ index,
6265
+ seed,
6266
+ xywh: formatXywhString(x, y, w, h),
6267
+ rotate: 0,
6268
+ text: yText,
6269
+ color: input.color ?? "--affine-text-primary-color",
6270
+ fontFamily: "blocksuite:surface:Inter",
6271
+ fontSize: input.fontSize ?? 16,
6272
+ fontStyle: "normal",
6273
+ fontWeight: input.fontWeight ?? "400",
6274
+ textAlign: "center",
6275
+ hasMaxWidth: false,
6276
+ };
6277
+ }
6278
+ function buildGroupElementData(elementId, seed, index, input) {
6279
+ const childMap = new Y.Map();
6280
+ for (const childId of input.children ?? []) {
6281
+ childMap.set(childId, true);
6282
+ }
6283
+ const yTitle = new Y.Text();
6284
+ if (input.title)
6285
+ yTitle.insert(0, input.title);
6286
+ return {
6287
+ type: "group",
6288
+ id: elementId,
6289
+ index,
6290
+ seed,
6291
+ children: childMap,
6292
+ title: yTitle,
6293
+ };
6294
+ }
6295
+ function nextSurfaceElementIndex(valueMap) {
6296
+ let maxIndex = null;
6297
+ for (const [, el] of valueMap.entries()) {
6298
+ if (!(el instanceof Y.Map))
6299
+ continue;
6300
+ const idx = el.get("index");
6301
+ if (typeof idx !== "string")
6302
+ continue;
6303
+ if (maxIndex === null || idx > maxIndex)
6304
+ maxIndex = idx;
6305
+ }
6306
+ return generateKeyBetween(maxIndex, null);
6307
+ }
6308
+ function buildSurfaceElementData(type, index, input) {
6309
+ const elementId = generateId();
6310
+ const seed = Math.floor(Math.random() * 2 ** 31);
6311
+ switch (type) {
6312
+ case "shape":
6313
+ return { elementId, data: buildShapeElementData(elementId, seed, index, input) };
6314
+ case "connector":
6315
+ return { elementId, data: buildConnectorElementData(elementId, seed, index, input) };
6316
+ case "text":
6317
+ return { elementId, data: buildTextElementData(elementId, seed, index, input) };
6318
+ case "group":
6319
+ return { elementId, data: buildGroupElementData(elementId, seed, index, input) };
6320
+ }
6321
+ }
6322
+ function writeSurfaceElement(valueMap, elementId, data) {
6323
+ const el = new Y.Map();
6324
+ for (const [k, v] of Object.entries(data)) {
6325
+ el.set(k, v);
6326
+ }
6327
+ valueMap.set(elementId, el);
6328
+ }
6329
+ const addSurfaceElementHandler = async (params) => {
6330
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6331
+ if (!workspaceId) {
6332
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6333
+ }
6334
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6335
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6336
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6337
+ try {
6338
+ await joinWorkspace(socket, workspaceId);
6339
+ const doc = new Y.Doc();
6340
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6341
+ if (snapshot.missing) {
6342
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6343
+ }
6344
+ const prevSV = Y.encodeStateVector(doc);
6345
+ const blocks = doc.getMap("blocks");
6346
+ const ctx = getSurfaceElementsValueMap(blocks, { create: true });
6347
+ const index = params.index ?? nextSurfaceElementIndex(ctx.value);
6348
+ // Mirror updateSurfaceElementHandler: report inapplicable gated fields
6349
+ // in `ignored` instead of silently dropping them at the per-type builder.
6350
+ const ignored = [];
6351
+ for (const [field, applicable] of Object.entries(FIELD_APPLICABILITY)) {
6352
+ if (params[field] === undefined)
6353
+ continue;
6354
+ if (!applicable.includes(params.type))
6355
+ ignored.push(field);
6356
+ }
6357
+ const { elementId, data } = buildSurfaceElementData(params.type, index, params);
6358
+ if (params.type === "connector") {
6359
+ const srcBounds = resolveConnectorEndpointBoundAsBound(ctx.value, blocks, params.sourceId);
6360
+ const tgtBounds = resolveConnectorEndpointBoundAsBound(ctx.value, blocks, params.targetId);
6361
+ // Auto-snap to the four tangent-carrying sides when the caller only
6362
+ // supplied ids — the renderer needs tangents to draw arrow heads.
6363
+ if (srcBounds && tgtBounds && !params.sourcePosition && !params.targetPosition) {
6364
+ const natural = pickConnectorSides(srcBounds, tgtBounds);
6365
+ data.source = { ...data.source, position: SIDE_TO_NORMALIZED_POSITION[natural.from] };
6366
+ data.target = { ...data.target, position: SIDE_TO_NORMALIZED_POSITION[natural.to] };
6367
+ }
6368
+ if (params.label) {
6369
+ const srcCenter = srcBounds
6370
+ ? { x: srcBounds.x + srcBounds.w / 2, y: srcBounds.y + srcBounds.h / 2 }
6371
+ : resolveConnectorEndpointCenter(ctx.value, blocks, params.sourceId, params.sourcePosition);
6372
+ const tgtCenter = tgtBounds
6373
+ ? { x: tgtBounds.x + tgtBounds.w / 2, y: tgtBounds.y + tgtBounds.h / 2 }
6374
+ : resolveConnectorEndpointCenter(ctx.value, blocks, params.targetId, params.targetPosition);
6375
+ const midpoint = srcCenter && tgtCenter
6376
+ ? { x: (srcCenter.x + tgtCenter.x) / 2, y: (srcCenter.y + tgtCenter.y) / 2 }
6377
+ : (srcCenter ?? tgtCenter);
6378
+ data.labelXYWH = estimateConnectorLabelXYWH(params.label, 16, midpoint, 280);
6379
+ }
6380
+ }
6381
+ writeSurfaceElement(ctx.value, elementId, data);
6382
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6383
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6384
+ return text({
6385
+ added: true,
6386
+ elementId,
6387
+ type: params.type,
6388
+ surfaceBlockId: ctx.surfaceId,
6389
+ ignored,
6390
+ });
6391
+ }
6392
+ finally {
6393
+ socket.disconnect();
6394
+ }
6395
+ };
6396
+ const listSurfaceElementsHandler = async (params) => {
6397
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6398
+ if (!workspaceId) {
6399
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6400
+ }
6401
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6402
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6403
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6404
+ try {
6405
+ await joinWorkspace(socket, workspaceId);
6406
+ const doc = new Y.Doc();
6407
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6408
+ if (!snapshot.missing) {
6409
+ return text({
6410
+ docId: params.docId,
6411
+ exists: false,
6412
+ surfaceBlockId: null,
6413
+ count: 0,
6414
+ elements: [],
6415
+ });
6416
+ }
6417
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6418
+ const blocks = doc.getMap("blocks");
6419
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
6420
+ if (!ctx) {
6421
+ return text({
6422
+ docId: params.docId,
6423
+ exists: true,
6424
+ surfaceBlockId: null,
6425
+ count: 0,
6426
+ elements: [],
6427
+ });
6428
+ }
6429
+ const elements = [];
6430
+ for (const [id, value] of ctx.value.entries()) {
6431
+ const entryId = String(id);
6432
+ if (params.elementId && entryId !== params.elementId)
6433
+ continue;
6434
+ if (!(value instanceof Y.Map))
6435
+ continue;
6436
+ const serialized = serializeSurfaceElement(entryId, value);
6437
+ if (params.type && serialized.type !== params.type)
6438
+ continue;
6439
+ elements.push(serialized);
6440
+ }
6441
+ const sorted = sortByFractionalIndex(elements);
6442
+ return text({
6443
+ docId: params.docId,
6444
+ exists: true,
6445
+ surfaceBlockId: ctx.surfaceId,
6446
+ count: sorted.length,
6447
+ elements: sorted,
6448
+ });
6449
+ }
6450
+ finally {
6451
+ socket.disconnect();
6452
+ }
6453
+ };
6454
+ // Also parsed by scripts/verify-surface-element-gating.mjs — adding a styling
6455
+ // field to surfaceElementFieldSchemas without a row here fails that gate.
6456
+ // Per-type rows mirror blocksuite's @field decorators (toeverything/blocksuite@5cb5cb6
6457
+ // packages/affine/model/src/elements/{connector,shape}.ts).
6458
+ const FIELD_APPLICABILITY = {
6459
+ shapeType: ["shape"],
6460
+ radius: ["shape"],
6461
+ filled: ["shape"],
6462
+ fillColor: ["shape"],
6463
+ strokeColor: ["shape"], // connectors use top-level `stroke`
6464
+ strokeWidth: ["shape", "connector"],
6465
+ strokeStyle: ["shape", "connector"],
6466
+ color: ["shape", "text"], // connectors use labelStyle.*
6467
+ fontSize: ["shape", "text"],
6468
+ fontWeight: ["shape", "text"],
6469
+ stroke: ["connector"],
6470
+ mode: ["connector"],
6471
+ frontEndpointStyle: ["connector"],
6472
+ rearEndpointStyle: ["connector"],
6473
+ };
6474
+ const updateSurfaceElementHandler = async (params) => {
6475
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6476
+ if (!workspaceId) {
6477
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6478
+ }
6479
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6480
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6481
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6482
+ try {
6483
+ await joinWorkspace(socket, workspaceId);
6484
+ const doc = new Y.Doc();
6485
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6486
+ if (!snapshot.missing) {
6487
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6488
+ }
6489
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6490
+ const prevSV = Y.encodeStateVector(doc);
6491
+ const blocks = doc.getMap("blocks");
6492
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
6493
+ if (!ctx)
6494
+ throw new Error("Document has no surface elements to update.");
6495
+ const el = ctx.value.get(params.elementId);
6496
+ if (!(el instanceof Y.Map)) {
6497
+ throw new Error(`Surface element '${params.elementId}' not found.`);
6498
+ }
6499
+ const elementType = el.get("type");
6500
+ const changed = [];
6501
+ const ignored = [];
6502
+ const geomProvided = params.x !== undefined ||
6503
+ params.y !== undefined ||
6504
+ params.width !== undefined ||
6505
+ params.height !== undefined;
6506
+ if (geomProvided) {
6507
+ if (elementType === "shape" || elementType === "text") {
6508
+ const current = parseXywhString(el.get("xywh")) ?? {
6509
+ x: 0,
6510
+ y: 0,
6511
+ width: 100,
6512
+ height: 100,
6513
+ };
6514
+ const nx = params.x ?? current.x;
6515
+ const ny = params.y ?? current.y;
6516
+ const nw = params.width ?? current.width;
6517
+ const nh = params.height ?? current.height;
6518
+ el.set("xywh", formatXywhString(nx, ny, nw, nh));
6519
+ changed.push("xywh");
6520
+ }
6521
+ else {
6522
+ if (params.x !== undefined)
6523
+ ignored.push("x");
6524
+ if (params.y !== undefined)
6525
+ ignored.push("y");
6526
+ if (params.width !== undefined)
6527
+ ignored.push("width");
6528
+ if (params.height !== undefined)
6529
+ ignored.push("height");
6530
+ }
6531
+ }
6532
+ const setYText = (key, value) => {
6533
+ const yText = new Y.Text();
6534
+ yText.insert(0, resolveSurfaceNewlines(value));
6535
+ el.set(key, yText);
6536
+ changed.push(key);
6537
+ };
6538
+ if (params.text !== undefined) {
6539
+ if (elementType === "shape" || elementType === "text" || elementType === "connector") {
6540
+ setYText("text", params.text);
6541
+ }
6542
+ else {
6543
+ ignored.push("text");
6544
+ }
6545
+ }
6546
+ if (params.label !== undefined) {
6547
+ if (elementType === "connector") {
6548
+ setYText("text", params.label);
6549
+ }
6550
+ else {
6551
+ ignored.push("label");
6552
+ }
6553
+ }
6554
+ if (params.title !== undefined) {
6555
+ if (elementType === "group") {
6556
+ setYText("title", params.title);
6557
+ }
6558
+ else {
6559
+ ignored.push("title");
6560
+ }
6561
+ }
6562
+ for (const [field, applicable] of Object.entries(FIELD_APPLICABILITY)) {
6563
+ const key = field;
6564
+ if (params[key] === undefined)
6565
+ continue;
6566
+ if (applicable.includes(elementType)) {
6567
+ el.set(field, params[key]);
6568
+ changed.push(field);
6569
+ }
6570
+ else {
6571
+ ignored.push(field);
6572
+ }
6573
+ }
6574
+ if (params.index !== undefined) {
6575
+ el.set("index", params.index);
6576
+ changed.push("index");
6577
+ }
6578
+ if (params.sourceId !== undefined || params.sourcePosition !== undefined) {
6579
+ if (elementType === "connector") {
6580
+ const source = {};
6581
+ if (params.sourceId)
6582
+ source.id = params.sourceId;
6583
+ if (params.sourcePosition)
6584
+ source.position = params.sourcePosition;
6585
+ el.set("source", source);
6586
+ changed.push("source");
6587
+ }
6588
+ else {
6589
+ if (params.sourceId !== undefined)
6590
+ ignored.push("sourceId");
6591
+ if (params.sourcePosition !== undefined)
6592
+ ignored.push("sourcePosition");
6593
+ }
6594
+ }
6595
+ if (params.targetId !== undefined || params.targetPosition !== undefined) {
6596
+ if (elementType === "connector") {
6597
+ const target = {};
6598
+ if (params.targetId)
6599
+ target.id = params.targetId;
6600
+ if (params.targetPosition)
6601
+ target.position = params.targetPosition;
6602
+ el.set("target", target);
6603
+ changed.push("target");
6604
+ }
6605
+ else {
6606
+ if (params.targetId !== undefined)
6607
+ ignored.push("targetId");
6608
+ if (params.targetPosition !== undefined)
6609
+ ignored.push("targetPosition");
6610
+ }
6611
+ }
6612
+ if (params.children !== undefined) {
6613
+ if (elementType === "group") {
6614
+ const childMap = new Y.Map();
6615
+ for (const childId of params.children)
6616
+ childMap.set(childId, true);
6617
+ el.set("children", childMap);
6618
+ changed.push("children");
6619
+ }
6620
+ else {
6621
+ ignored.push("children");
6622
+ }
6623
+ }
6624
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6625
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6626
+ return text({
6627
+ updated: changed.length > 0,
6628
+ elementId: params.elementId,
6629
+ type: typeof elementType === "string" ? elementType : null,
6630
+ changed,
6631
+ ignored,
6632
+ });
6633
+ }
6634
+ finally {
6635
+ socket.disconnect();
6636
+ }
6637
+ };
6638
+ const deleteSurfaceElementHandler = async (params) => {
6639
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6640
+ if (!workspaceId) {
6641
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6642
+ }
6643
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6644
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6645
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6646
+ try {
6647
+ await joinWorkspace(socket, workspaceId);
6648
+ const doc = new Y.Doc();
6649
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6650
+ if (!snapshot.missing) {
6651
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6652
+ }
6653
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6654
+ const prevSV = Y.encodeStateVector(doc);
6655
+ const blocks = doc.getMap("blocks");
6656
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
6657
+ if (!ctx) {
6658
+ return text({
6659
+ deleted: false,
6660
+ elementId: params.elementId,
6661
+ reason: "no-surface",
6662
+ prunedConnectors: [],
6663
+ });
6664
+ }
6665
+ const existing = ctx.value.get(params.elementId);
6666
+ if (!(existing instanceof Y.Map)) {
6667
+ return text({
6668
+ deleted: false,
6669
+ elementId: params.elementId,
6670
+ reason: "not-found",
6671
+ prunedConnectors: [],
6672
+ });
6673
+ }
6674
+ ctx.value.delete(params.elementId);
6675
+ const prunedConnectors = [];
6676
+ if (params.pruneConnectors) {
6677
+ const toDelete = [];
6678
+ for (const [otherId, otherVal] of ctx.value.entries()) {
6679
+ if (!(otherVal instanceof Y.Map))
6680
+ continue;
6681
+ if (otherVal.get("type") !== "connector")
6682
+ continue;
6683
+ const source = otherVal.get("source");
6684
+ const target = otherVal.get("target");
6685
+ const srcId = source && typeof source === "object" ? source.id : undefined;
6686
+ const tgtId = target && typeof target === "object" ? target.id : undefined;
6687
+ if (srcId === params.elementId || tgtId === params.elementId) {
6688
+ toDelete.push(String(otherId));
6689
+ }
6690
+ }
6691
+ for (const id of toDelete) {
6692
+ ctx.value.delete(id);
6693
+ prunedConnectors.push(id);
6694
+ }
6695
+ }
6696
+ pruneFromFrameChildElementIds(blocks, [params.elementId, ...prunedConnectors]);
6697
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6698
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6699
+ return text({ deleted: true, elementId: params.elementId, prunedConnectors });
6700
+ }
6701
+ finally {
6702
+ socket.disconnect();
6703
+ }
6704
+ };
6705
+ const updateFrameChildrenHandler = async (params) => {
6706
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6707
+ if (!workspaceId) {
6708
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6709
+ }
6710
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6711
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6712
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6713
+ try {
6714
+ await joinWorkspace(socket, workspaceId);
6715
+ const doc = new Y.Doc();
6716
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6717
+ if (!snapshot.missing) {
6718
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6719
+ }
6720
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6721
+ const prevSV = Y.encodeStateVector(doc);
6722
+ const blocks = doc.getMap("blocks");
6723
+ const frameBlock = blocks.get(params.blockId);
6724
+ if (!(frameBlock instanceof Y.Map) || frameBlock.get("sys:flavour") !== "affine:frame") {
6725
+ throw new Error(`Frame block '${params.blockId}' not found.`);
6726
+ }
6727
+ // All-missing is legit here: it's how callers clear ownership.
6728
+ const surfaceCtx = getSurfaceElementsValueMap(blocks, { create: false });
6729
+ const surfaceValueMap = surfaceCtx?.value ?? new Y.Map();
6730
+ const ownedIds = [];
6731
+ const missing = [];
6732
+ const kids = [];
6733
+ for (const id of params.childElementIds) {
6734
+ const resolved = resolveChildBound(surfaceValueMap, blocks, id);
6735
+ if (resolved.kind === "missing") {
6736
+ missing.push(id);
6737
+ }
6738
+ else {
6739
+ ownedIds.push(id);
6740
+ if (resolved.bound)
6741
+ kids.push(resolved.bound);
6742
+ }
6743
+ }
6744
+ const childMap = new Y.Map();
6745
+ for (const id of ownedIds)
6746
+ childMap.set(id, true);
6747
+ frameBlock.set("prop:childElementIds", childMap);
6748
+ // Resize-to-fit is default; skip on all-missing so clear-ownership
6749
+ // doesn't collapse the frame to zero.
6750
+ const resize = params.resizeToFit !== false;
6751
+ const padding = Number.isFinite(params.padding)
6752
+ ? Math.max(0, Math.floor(params.padding))
6753
+ : 40;
6754
+ let xywh = null;
6755
+ if (resize && kids.length > 0) {
6756
+ const wrapped = encloseBounds(kids, { padding, titleBand: 60 });
6757
+ if (wrapped) {
6758
+ xywh = { x: wrapped.x, y: wrapped.y, width: wrapped.w, height: wrapped.h };
6759
+ frameBlock.set("prop:xywh", formatXywhString(xywh.x, xywh.y, xywh.width, xywh.height));
6760
+ }
6761
+ }
6762
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6763
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6764
+ return text({
6765
+ updated: true,
6766
+ blockId: params.blockId,
6767
+ flavour: "affine:frame",
6768
+ ownedIds,
6769
+ missing,
6770
+ resized: xywh !== null,
6771
+ ...(xywh ? { xywh } : {}),
6772
+ });
6773
+ }
6774
+ finally {
6775
+ socket.disconnect();
6776
+ }
6777
+ };
6778
+ const updateEdgelessBlockHandler = async (params) => {
6779
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6780
+ if (!workspaceId) {
6781
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6782
+ }
6783
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6784
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6785
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6786
+ try {
6787
+ await joinWorkspace(socket, workspaceId);
6788
+ const doc = new Y.Doc();
6789
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6790
+ if (!snapshot.missing) {
6791
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6792
+ }
6793
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6794
+ const prevSV = Y.encodeStateVector(doc);
6795
+ const blocks = doc.getMap("blocks");
6796
+ const block = blocks.get(params.blockId);
6797
+ if (!(block instanceof Y.Map)) {
6798
+ throw new Error(`Block '${params.blockId}' not found.`);
6799
+ }
6800
+ const flavour = block.get("sys:flavour");
6801
+ if (flavour !== "affine:note" && flavour !== "affine:frame" && flavour !== "affine:edgeless-text") {
6802
+ throw new Error(`Block '${params.blockId}' has flavour '${String(flavour)}' — update_edgeless_block only mutates note/frame/edgeless-text blocks.`);
6803
+ }
6804
+ const changed = [];
6805
+ const ignored = [];
6806
+ if (params.x !== undefined || params.y !== undefined || params.width !== undefined || params.height !== undefined) {
6807
+ const prev = parseXywhString(block.get("prop:xywh")) ?? { x: 0, y: 0, width: 0, height: 0 };
6808
+ block.set("prop:xywh", formatXywhString(params.x ?? prev.x, params.y ?? prev.y, params.width ?? prev.width, params.height ?? prev.height));
6809
+ changed.push("xywh");
6810
+ }
6811
+ if (params.background !== undefined) {
6812
+ if (flavour === "affine:edgeless-text") {
6813
+ ignored.push("background"); // edgeless-text has no prop:background
6814
+ }
6815
+ else {
6816
+ const bg = params.background;
6817
+ if (bg && typeof bg === "object" && !Array.isArray(bg) && ("light" in bg || "dark" in bg)) {
6818
+ const bgMap = new Y.Map();
6819
+ if (typeof bg.light === "string")
6820
+ bgMap.set("light", bg.light);
6821
+ if (typeof bg.dark === "string")
6822
+ bgMap.set("dark", bg.dark);
6823
+ block.set("prop:background", bgMap);
6824
+ }
6825
+ else {
6826
+ block.set("prop:background", bg);
6827
+ }
6828
+ changed.push("background");
6829
+ }
6830
+ }
6831
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6832
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6833
+ return text({ updated: changed.length > 0, blockId: params.blockId, flavour, changed, ignored });
6834
+ }
6835
+ finally {
6836
+ socket.disconnect();
6837
+ }
6838
+ };
6839
+ const deleteBlockHandler = async (params) => {
6840
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6841
+ if (!workspaceId) {
6842
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6843
+ }
6844
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6845
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6846
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6847
+ try {
6848
+ await joinWorkspace(socket, workspaceId);
6849
+ const doc = new Y.Doc();
6850
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6851
+ if (!snapshot.missing) {
6852
+ throw new Error(`Document '${params.docId}' not found or has no content.`);
6853
+ }
6854
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6855
+ const prevSV = Y.encodeStateVector(doc);
6856
+ const blocks = doc.getMap("blocks");
6857
+ const block = blocks.get(params.blockId);
6858
+ if (!(block instanceof Y.Map)) {
6859
+ return text({ deleted: false, blockId: params.blockId, reason: "not-found" });
6860
+ }
6861
+ const flavour = block.get("sys:flavour");
6862
+ if (flavour === "affine:page") {
6863
+ throw new Error(`Refusing to delete page-root block '${params.blockId}' — use delete_doc for whole-doc removal.`);
6864
+ }
6865
+ const deletedIds = [];
6866
+ const deleteRecursive = params.deleteChildren !== false;
6867
+ const walk = (id) => {
6868
+ const b = blocks.get(id);
6869
+ if (!(b instanceof Y.Map))
6870
+ return;
6871
+ if (deleteRecursive) {
6872
+ const children = b.get("sys:children");
6873
+ if (children instanceof Y.Array) {
6874
+ for (const c of children.toArray()) {
6875
+ if (typeof c === "string")
6876
+ walk(c);
6877
+ }
6878
+ }
6879
+ }
6880
+ blocks.delete(id);
6881
+ deletedIds.push(id);
6882
+ };
6883
+ walk(params.blockId);
6884
+ for (const [, maybeParent] of blocks.entries()) {
6885
+ if (!(maybeParent instanceof Y.Map))
6886
+ continue;
6887
+ const kids = maybeParent.get("sys:children");
6888
+ if (!(kids instanceof Y.Array))
6889
+ continue;
6890
+ const arr = kids.toArray();
6891
+ for (let i = arr.length - 1; i >= 0; i--) {
6892
+ if (typeof arr[i] === "string" && deletedIds.includes(arr[i])) {
6893
+ kids.delete(i, 1);
6894
+ }
6895
+ }
6896
+ }
6897
+ // Mirror delete_surface_element's pruneConnectors semantics.
6898
+ const prunedConnectors = [];
6899
+ if (params.pruneConnectors) {
6900
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
6901
+ if (ctx) {
6902
+ const toDelete = [];
6903
+ for (const [otherId, otherVal] of ctx.value.entries()) {
6904
+ if (!(otherVal instanceof Y.Map))
6905
+ continue;
6906
+ if (otherVal.get("type") !== "connector")
6907
+ continue;
6908
+ const source = otherVal.get("source");
6909
+ const target = otherVal.get("target");
6910
+ const srcId = source && typeof source === "object" ? source.id : undefined;
6911
+ const tgtId = target && typeof target === "object" ? target.id : undefined;
6912
+ if ((typeof srcId === "string" && deletedIds.includes(srcId)) ||
6913
+ (typeof tgtId === "string" && deletedIds.includes(tgtId))) {
6914
+ toDelete.push(String(otherId));
6915
+ }
6916
+ }
6917
+ for (const id of toDelete) {
6918
+ ctx.value.delete(id);
6919
+ prunedConnectors.push(id);
6920
+ }
6921
+ }
6922
+ }
6923
+ pruneFromFrameChildElementIds(blocks, [...deletedIds, ...prunedConnectors]);
6924
+ const delta = Y.encodeStateAsUpdate(doc, prevSV);
6925
+ await pushDocUpdate(socket, workspaceId, params.docId, Buffer.from(delta).toString("base64"));
6926
+ return text({ deleted: true, blockId: params.blockId, deletedIds, prunedConnectors });
6927
+ }
6928
+ finally {
6929
+ socket.disconnect();
6930
+ }
6931
+ };
6932
+ const getEdgelessCanvasHandler = async (params) => {
6933
+ const workspaceId = params.workspaceId || defaults.workspaceId;
6934
+ if (!workspaceId) {
6935
+ throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
6936
+ }
6937
+ const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
6938
+ const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
6939
+ const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
6940
+ try {
6941
+ await joinWorkspace(socket, workspaceId);
6942
+ const doc = new Y.Doc();
6943
+ const snapshot = await loadDoc(socket, workspaceId, params.docId);
6944
+ if (!snapshot.missing) {
6945
+ return text({
6946
+ docId: params.docId,
6947
+ exists: false,
6948
+ surfaceBlockId: null,
6949
+ edgelessBlocks: [],
6950
+ surfaceElements: [],
6951
+ bounds: null,
6952
+ elementCounts: { shape: 0, connector: 0, text: 0, group: 0 },
6953
+ });
6954
+ }
6955
+ Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
6956
+ const blocks = doc.getMap("blocks");
6957
+ const edgelessFlavours = new Set([
6958
+ "affine:frame",
6959
+ "affine:edgeless-text",
6960
+ "affine:note",
6961
+ ]);
6962
+ const collectNoteText = (rootId) => {
6963
+ const out = [];
6964
+ const seen = new Set();
6965
+ const visit = (id) => {
6966
+ if (seen.has(id))
6967
+ return;
6968
+ seen.add(id);
6969
+ const b = blocks.get(id);
6970
+ if (!(b instanceof Y.Map))
6971
+ return;
6972
+ const t = asText(b.get("prop:text"));
6973
+ if (t)
6974
+ out.push(t);
6975
+ for (const childId of childIdsFrom(b.get("sys:children"))) {
6976
+ visit(childId);
6977
+ }
6978
+ };
6979
+ const root = blocks.get(rootId);
6980
+ if (root instanceof Y.Map) {
6981
+ for (const childId of childIdsFrom(root.get("sys:children"))) {
6982
+ visit(childId);
6983
+ }
6984
+ }
6985
+ return out;
6986
+ };
6987
+ // Structured tree rather than flat-joined text so markdown-seeded notes
6988
+ // round-trip with heading/paragraph/list structure intact.
6989
+ const collectNoteChildren = (rootId) => {
6990
+ const result = [];
6991
+ const seen = new Set();
6992
+ const visit = (id) => {
6993
+ if (seen.has(id))
6994
+ return null;
6995
+ seen.add(id);
6996
+ const b = blocks.get(id);
6997
+ if (!(b instanceof Y.Map))
6998
+ return null;
6999
+ const childFlavour = b.get("sys:flavour");
7000
+ if (typeof childFlavour !== "string")
7001
+ return null;
7002
+ const entry = { id, flavour: childFlavour };
7003
+ const t = asText(b.get("prop:text"));
7004
+ if (t)
7005
+ entry.text = t;
7006
+ const propType = b.get("prop:type");
7007
+ if (typeof propType === "string")
7008
+ entry.type = propType;
7009
+ const checked = b.get("prop:checked");
7010
+ if (typeof checked === "boolean")
7011
+ entry.checked = checked;
7012
+ const language = b.get("prop:language");
7013
+ if (typeof language === "string" && language.length > 0)
7014
+ entry.language = language;
7015
+ const subChildren = [];
7016
+ for (const childId of childIdsFrom(b.get("sys:children"))) {
7017
+ const c = visit(childId);
7018
+ if (c)
7019
+ subChildren.push(c);
7020
+ }
7021
+ if (subChildren.length)
7022
+ entry.children = subChildren;
7023
+ return entry;
7024
+ };
7025
+ const root = blocks.get(rootId);
7026
+ if (root instanceof Y.Map) {
7027
+ for (const childId of childIdsFrom(root.get("sys:children"))) {
7028
+ const c = visit(childId);
7029
+ if (c)
7030
+ result.push(c);
7031
+ }
7032
+ }
7033
+ return result;
7034
+ };
7035
+ const edgelessBlocks = [];
7036
+ for (const [id, raw] of blocks.entries()) {
7037
+ if (!(raw instanceof Y.Map))
7038
+ continue;
7039
+ const flavour = raw.get("sys:flavour");
7040
+ if (typeof flavour !== "string" || !edgelessFlavours.has(flavour))
7041
+ continue;
7042
+ const xywhRaw = raw.get("prop:xywh");
7043
+ const bounds = parseXywhString(xywhRaw);
7044
+ const propIndex = raw.get("prop:index");
7045
+ const entry = {
7046
+ id: String(id),
7047
+ flavour,
7048
+ xywh: typeof xywhRaw === "string" ? xywhRaw : null,
7049
+ bounds,
7050
+ index: typeof propIndex === "string" ? propIndex : null,
7051
+ };
7052
+ if (flavour === "affine:frame") {
7053
+ entry.title = asText(raw.get("prop:title")) || null;
7054
+ const bg = raw.get("prop:background");
7055
+ entry.background = bg instanceof Y.Map ? bg.toJSON() : bg ?? null;
7056
+ const owned = raw.get("prop:childElementIds");
7057
+ entry.childElementIds = owned instanceof Y.Map ? Object.keys(owned.toJSON()) : [];
7058
+ }
7059
+ else if (flavour === "affine:edgeless-text") {
7060
+ const lines = collectNoteText(String(id));
7061
+ entry.text = lines.length ? lines.join("\n") : null;
7062
+ entry.color = raw.get("prop:color") ?? null;
7063
+ }
7064
+ else if (flavour === "affine:note") {
7065
+ const lines = collectNoteText(String(id));
7066
+ entry.text = lines.length ? lines.join("\n") : null;
7067
+ // `text` is the flat-join legacy view; `children` carries structure.
7068
+ entry.children = collectNoteChildren(String(id));
7069
+ entry.displayMode = raw.get("prop:displayMode") ?? null;
7070
+ const bg = raw.get("prop:background");
7071
+ entry.background = bg instanceof Y.Map ? bg.toJSON() : bg ?? null;
7072
+ }
7073
+ edgelessBlocks.push(entry);
7074
+ }
7075
+ const ctx = getSurfaceElementsValueMap(blocks, { create: false });
7076
+ const surfaceElements = [];
7077
+ const counts = {
7078
+ shape: 0,
7079
+ connector: 0,
7080
+ text: 0,
7081
+ group: 0,
7082
+ };
7083
+ if (ctx) {
7084
+ for (const [elId, val] of ctx.value.entries()) {
7085
+ if (!(val instanceof Y.Map))
7086
+ continue;
7087
+ const serialized = serializeSurfaceElement(String(elId), val);
7088
+ surfaceElements.push(serialized);
7089
+ const t = serialized.type;
7090
+ if (t && t in counts)
7091
+ counts[t]++;
7092
+ }
7093
+ }
7094
+ const sortedSurfaceElements = sortByFractionalIndex(surfaceElements);
7095
+ let minX = Infinity;
7096
+ let minY = Infinity;
7097
+ let maxX = -Infinity;
7098
+ let maxY = -Infinity;
7099
+ let hasAny = false;
7100
+ const include = (b) => {
7101
+ if (!b)
7102
+ return;
7103
+ hasAny = true;
7104
+ minX = Math.min(minX, b.x);
7105
+ minY = Math.min(minY, b.y);
7106
+ maxX = Math.max(maxX, b.x + b.width);
7107
+ maxY = Math.max(maxY, b.y + b.height);
7108
+ };
7109
+ const sortedEdgelessBlocks = sortByFractionalIndex(edgelessBlocks);
7110
+ for (const eb of sortedEdgelessBlocks)
7111
+ include(eb.bounds);
7112
+ for (const se of sortedSurfaceElements)
7113
+ include(se.bounds);
7114
+ return text({
7115
+ docId: params.docId,
7116
+ exists: true,
7117
+ surfaceBlockId: ctx?.surfaceId ?? null,
7118
+ edgelessBlocks: sortedEdgelessBlocks,
7119
+ surfaceElements: sortedSurfaceElements,
7120
+ elementCounts: counts,
7121
+ bounds: hasAny
7122
+ ? { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY }
7123
+ : null,
7124
+ });
7125
+ }
7126
+ finally {
7127
+ socket.disconnect();
7128
+ }
7129
+ };
7130
+ const surfaceElementFieldSchemas = {
7131
+ x: z.number().optional().describe("X position on canvas (shape/text; default 0)."),
7132
+ y: z.number().optional().describe("Y position on canvas (shape/text; default 0)."),
7133
+ width: z.number().optional().describe("Width (shape default 100, text default 200)."),
7134
+ height: z.number().optional().describe("Height (shape default 100, text default 30)."),
7135
+ shapeType: z
7136
+ .enum(["rect", "ellipse", "diamond", "triangle"])
7137
+ .optional()
7138
+ .describe("Shape type (default rect). Shape only."),
7139
+ radius: z.number().optional().describe("Corner radius for rect (0.1 = rounded). Shape only."),
7140
+ filled: z.boolean().optional().describe("Whether shape is filled (default true). Shape only."),
7141
+ fillColor: z
7142
+ .string()
7143
+ .optional()
7144
+ .describe("Fill color. Prefer the `--affine-palette-shape-<color>` family (yellow/orange/red/magenta/purple/navy/blue/green/teal/grey/white/black). These are fixed colors — AFFiNE shape colors are not theme-adaptive by design. Shape only."),
7145
+ strokeColor: z.string().optional().describe("Stroke color. Prefer the `--affine-palette-line-<color>` family (same color names as fillColor). Fixed colors, not theme-adaptive. Shape only."),
7146
+ strokeWidth: z.number().optional().describe("Stroke width (default 2). Shape/connector."),
7147
+ strokeStyle: z
7148
+ .enum(["solid", "dash", "none"])
7149
+ .optional()
7150
+ .describe("Stroke style. Shape/connector."),
7151
+ text: z
7152
+ .string()
7153
+ .optional()
7154
+ .describe("Text content (shape/text) or connector label. Replaces existing Y.Text on update."),
7155
+ color: z.string().optional().describe("Text color. Shape default `#000000` — keep unless the fill is dark, then pass a contrasting hex. Canvas text default `--affine-text-primary-color` (theme-adaptive). Shape/text."),
7156
+ fontSize: z
7157
+ .number()
7158
+ .optional()
7159
+ .describe("Font size (shape default 20, text default 16). Shape/text."),
7160
+ fontWeight: z
7161
+ .string()
7162
+ .optional()
7163
+ .describe("Font weight (shape default 600, text default 400). Shape/text."),
7164
+ sourceId: z.string().optional().describe("Connector source element id. Connector only."),
7165
+ targetId: z.string().optional().describe("Connector target element id. Connector only."),
7166
+ sourcePosition: z
7167
+ .array(z.number())
7168
+ .length(2)
7169
+ .optional()
7170
+ .describe("Source [x,y]: relative [0-1] if sourceId set, absolute otherwise. Connector only."),
7171
+ targetPosition: z
7172
+ .array(z.number())
7173
+ .length(2)
7174
+ .optional()
7175
+ .describe("Target [x,y]: relative [0-1] if targetId set, absolute otherwise. Connector only. When both source/target are bound by id and neither position is provided, endpoints snap to the BlockSuite side-midpoint facing the other endpoint so connectors flow in a clear direction. Pass [0.5,0] top, [0.5,1] bottom, [0,0.5] left, [1,0.5] right to force a specific side."),
7176
+ mode: z
7177
+ .number()
7178
+ .optional()
7179
+ .describe("Connector mode: 0=straight, 1=orthogonal (elbow), 2=curve (default 2). Connector only."),
7180
+ frontEndpointStyle: z
7181
+ .enum(["None", "Arrow", "Triangle", "Circle", "Diamond"])
7182
+ .optional()
7183
+ .describe("Front endpoint style (default None). Connector only."),
7184
+ rearEndpointStyle: z
7185
+ .enum(["None", "Arrow", "Triangle", "Circle", "Diamond"])
7186
+ .optional()
7187
+ .describe("Rear endpoint style (default Arrow). Connector only."),
7188
+ stroke: z.string().optional().describe("Connector stroke color (default '--affine-text-primary-color' — theme-adaptive, near-black in light / near-white in dark). Accepts any CSS color or AFFiNE palette token. Connector only."),
7189
+ label: z.string().optional().describe("Connector label (stored as text on the connector). Connector only."),
7190
+ children: z.array(z.string()).optional().describe("Child element ids. Group only."),
7191
+ title: z.string().optional().describe("Group title. Group only."),
7192
+ index: z
7193
+ .string()
7194
+ .optional()
7195
+ .describe("BlockSuite fractional-index string controlling z-order. On add, defaults to a key above every existing element's index (new elements render on top). On update, replaces the stored value — pass a key less than some existing index to send-to-back, or greater to bring-to-front. Use the value returned by list_surface_elements to pick a specific position."),
7196
+ };
7197
+ server.registerTool("add_surface_element", {
7198
+ title: "Add Surface Element",
7199
+ description: "Add a shape, connector, text, or group to the AFFiNE edgeless canvas surface. Shapes support rect/ellipse/diamond/triangle with fill, stroke, and text. Connectors draw arrows between shapes (by id) or between absolute points. Use for building diagrams programmatically. Style fields that don't apply to the chosen element type are reported in the response 'ignored' list (mirrors update_surface_element).",
7200
+ inputSchema: {
7201
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7202
+ docId: DocId.describe("Document ID"),
7203
+ type: z.enum(["shape", "connector", "text", "group"]).describe("Element type"),
7204
+ ...surfaceElementFieldSchemas,
7205
+ },
7206
+ }, addSurfaceElementHandler);
7207
+ server.registerTool("list_surface_elements", {
7208
+ title: "List Surface Elements",
7209
+ description: "List all shape/connector/text/group elements on the AFFiNE edgeless canvas surface. Returns raw xywh strings plus parsed {x,y,width,height} bounds, with Y.Text fields serialized to plain strings. Optional filters by element type or id.",
7210
+ inputSchema: {
7211
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7212
+ docId: DocId.describe("Document ID"),
7213
+ type: z
7214
+ .enum(["shape", "connector", "text", "group"])
7215
+ .optional()
7216
+ .describe("Filter by element type"),
7217
+ elementId: z.string().optional().describe("Filter to a single element id"),
7218
+ },
7219
+ }, listSurfaceElementsHandler);
7220
+ server.registerTool("update_surface_element", {
7221
+ title: "Update Surface Element",
7222
+ description: "Partially update a surface element by id. x/y/width/height merge with the element's current xywh (move without resizing, or vice versa). text/label/title replace their Y.Text wholesale. Fields that don't apply to the element's type are reported in the response 'ignored' list.",
7223
+ inputSchema: {
7224
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7225
+ docId: DocId.describe("Document ID"),
7226
+ elementId: z.string().min(1).describe("Element ID to update"),
7227
+ ...surfaceElementFieldSchemas,
7228
+ },
7229
+ }, updateSurfaceElementHandler);
7230
+ server.registerTool("delete_surface_element", {
7231
+ title: "Delete Surface Element",
7232
+ description: "Delete a surface element by id. Set pruneConnectors=true to also delete any connectors whose source or target referenced the deleted element.",
7233
+ inputSchema: {
7234
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7235
+ docId: DocId.describe("Document ID"),
7236
+ elementId: z.string().min(1).describe("Element ID to delete"),
7237
+ pruneConnectors: z
7238
+ .boolean()
7239
+ .optional()
7240
+ .describe("Also delete connectors referencing this element (default false)"),
7241
+ },
7242
+ }, deleteSurfaceElementHandler);
7243
+ server.registerTool("update_frame_children", {
7244
+ title: "Update Frame Children",
7245
+ description: "Replace a frame block's contents wholesale. Accepts ids of surface elements (shapes/connectors/groups) AND edgeless blocks (notes/frames/edgeless-text) — all go into BlockSuite's prop:childElementIds map, matching what the editor writes when you drag members into a frame. Dragging the frame drags every owned member. Ids that don't resolve come back under 'missing'. By default the frame is resized to fit its new contents (plus padding + title band); set resizeToFit=false to leave xywh untouched. Pass `[]` to clear ownership (resize is skipped in that case).",
7246
+ inputSchema: {
7247
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7248
+ docId: DocId.describe("Document ID"),
7249
+ blockId: z.string().min(1).describe("Frame block id (flavour affine:frame)."),
7250
+ childElementIds: z
7251
+ .array(z.string())
7252
+ .describe("Full list of ids the frame should own/contain. Replaces any existing ownership."),
7253
+ resizeToFit: z
7254
+ .boolean()
7255
+ .optional()
7256
+ .describe("If true (default), recompute xywh from the union of resolvable child bounds + padding + title band. Set to false to preserve the frame's current box (useful when you want ownership-only edits or manual positioning)."),
7257
+ padding: z
7258
+ .number()
7259
+ .int()
7260
+ .optional()
7261
+ .describe("Padding (px) used when resizeToFit is true (default 40). Ignored when resizeToFit=false."),
7262
+ },
7263
+ }, updateFrameChildrenHandler);
7264
+ server.registerTool("update_edgeless_block", {
7265
+ title: "Update Edgeless Block",
7266
+ description: "Partially update a note/frame/edgeless-text block by id. x/y/width/height merge with current prop:xywh (move without resizing, or vice versa). background replaces prop:background (AFFiNE token or `{light, dark}` hex object). Fields that don't apply to the block's flavour come back under `ignored`.",
7267
+ inputSchema: {
7268
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7269
+ docId: DocId.describe("Document ID"),
7270
+ blockId: z.string().min(1).describe("Block id (flavour affine:note/affine:frame/affine:edgeless-text)."),
7271
+ x: z.number().optional(),
7272
+ y: z.number().optional(),
7273
+ width: z.number().optional(),
7274
+ height: z.number().optional(),
7275
+ background: z
7276
+ .union([
7277
+ z.string(),
7278
+ z.object({ light: z.string().optional(), dark: z.string().optional() }),
7279
+ ])
7280
+ .optional()
7281
+ .describe("Note/frame only. Prefer `--affine-note-background-<color>` or `{light, dark}` hex."),
7282
+ },
7283
+ }, updateEdgelessBlockHandler);
7284
+ server.registerTool("delete_block", {
7285
+ title: "Delete Block",
7286
+ description: "Delete a block by id. Removes descendants and unlinks from the parent's sys:children by default; set deleteChildren=false to keep descendants orphaned (for re-parenting), or pruneConnectors=true to also drop surface connectors referencing any deleted id. Refuses affine:page — use delete_doc for whole docs.",
7287
+ inputSchema: {
7288
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7289
+ docId: DocId.describe("Document ID"),
7290
+ blockId: z.string().min(1).describe("Block id to delete."),
7291
+ deleteChildren: z.boolean().optional().describe("Also delete descendants (default true)."),
7292
+ pruneConnectors: z.boolean().optional().describe("Also delete connectors bound to any deleted id (default false)."),
7293
+ },
7294
+ }, deleteBlockHandler);
7295
+ server.registerTool("get_edgeless_canvas", {
7296
+ title: "Get Edgeless Canvas",
7297
+ description: "Read the full edgeless canvas: all edgeless-positioned blocks (notes, frames, edgeless-text) with their xywh, plus all surface elements (shapes, connectors, text, groups). Includes aggregate bounding box and per-type element counts. Use this when you need to understand canvas layout end-to-end before placing new elements.",
7298
+ inputSchema: {
7299
+ workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
7300
+ docId: DocId.describe("Document ID"),
7301
+ },
7302
+ }, getEdgelessCanvasHandler);
4666
7303
  }