affine-mcp-server 1.11.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted or cloud). It exposes AFFiNE workspaces and documents to AI assistants over stdio (default) or HTTP (`/mcp`).
4
4
 
5
- [![Version](https://img.shields.io/badge/version-1.11.1-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
5
+ [![Version](https://img.shields.io/badge/version-1.12.0-blue)](https://github.com/dawncr0w/affine-mcp-server/releases)
6
6
  [![MCP SDK](https://img.shields.io/badge/MCP%20SDK-1.17.2-green)](https://github.com/modelcontextprotocol/typescript-sdk)
7
7
  [![CI](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml/badge.svg)](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml)
8
8
  [![License](https://img.shields.io/badge/license-MIT-yellow)](LICENSE)
@@ -19,7 +19,7 @@ A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted
19
19
  - Tools: 76 focused tools with WebSocket-based document editing
20
20
  - Status: Active
21
21
 
22
- > New in v1.11.1: Corrected stale `list_docs` count and cursor metadata after `delete_doc` removes a document.
22
+ > New in v1.12.0: Added linked documents on database rows, restored MCP CRUD for rows created in the AFFiNE UI, fixed self-hosted table exports, and documented GHCR Docker releases.
23
23
 
24
24
  ## Features
25
25
 
@@ -251,6 +251,42 @@ If you prefer `npx`:
251
251
  }
252
252
  ```
253
253
 
254
+ ### Docker
255
+
256
+ Pre-built multi-arch images (`linux/amd64`, `linux/arm64`) are published to the GitHub Container Registry on every release tag:
257
+
258
+ ```
259
+ ghcr.io/dawncr0w/affine-mcp-server:latest # latest release
260
+ ghcr.io/dawncr0w/affine-mcp-server:1.12.0 # specific version
261
+ ```
262
+
263
+ Quick start:
264
+
265
+ ```bash
266
+ docker run -d \
267
+ -p 3000:3000 \
268
+ -e AFFINE_BASE_URL=https://your-affine-instance.com \
269
+ -e AFFINE_API_TOKEN=ut_your_token \
270
+ -e AFFINE_MCP_HTTP_TOKEN=your-strong-secret \
271
+ ghcr.io/dawncr0w/affine-mcp-server:latest
272
+ ```
273
+
274
+ Then add to your MCP client config:
275
+
276
+ ```json
277
+ {
278
+ "mcpServers": {
279
+ "affine": {
280
+ "type": "http",
281
+ "url": "http://localhost:3000/mcp",
282
+ "headers": { "Authorization": "Bearer your-strong-secret" }
283
+ }
284
+ }
285
+ }
286
+ ```
287
+
288
+ The container runs as a non-root user and exposes `/healthz` and `/readyz` for liveness/readiness probes.
289
+
254
290
  ### Remote Server
255
291
 
256
292
  If you want to host the server remotely (e.g., using Render, Railway, Docker, or a VPS) and connect via HTTP MCP (Streamable HTTP on `/mcp`) instead of local `stdio`, run the server in HTTP mode.
@@ -103,6 +103,35 @@ export function registerDocTools(server, gql, defaults) {
103
103
  }
104
104
  return yText;
105
105
  }
106
+ /**
107
+ * Build a Y.Text containing a LinkedPage reference delta.
108
+ * This is the mechanism AFFiNE uses to associate a database row with a
109
+ * linked doc that opens in "center peek" when the row title is clicked.
110
+ */
111
+ function makeLinkedDocText(docId) {
112
+ const delta = [{ insert: "\u200B", attributes: { reference: { type: "LinkedPage", pageId: docId } } }];
113
+ // Cast needed: TextDelta.attributes doesn't declare `reference`, but
114
+ // makeText spreads all attributes at runtime via `{ ...delta.attributes }`.
115
+ return makeText(delta);
116
+ }
117
+ /**
118
+ * Extract a linked-doc page ID from a database row block's prop:text,
119
+ * if it contains a LinkedPage reference delta. Returns null otherwise.
120
+ */
121
+ function readLinkedDocId(rowBlock) {
122
+ const propText = rowBlock.get("prop:text");
123
+ if (!(propText instanceof Y.Text))
124
+ return null;
125
+ const delta = propText.toDelta();
126
+ if (!Array.isArray(delta))
127
+ return null;
128
+ for (const d of delta) {
129
+ if (d.attributes?.reference?.type === "LinkedPage" && d.attributes.reference.pageId) {
130
+ return d.attributes.reference.pageId;
131
+ }
132
+ }
133
+ return null;
134
+ }
106
135
  function asText(value) {
107
136
  if (value instanceof Y.Text)
108
137
  return value.toString();
@@ -1690,7 +1719,7 @@ export function registerDocTools(server, gql, defaults) {
1690
1719
  const rowsValue = block.get("prop:rows");
1691
1720
  const columnsValue = block.get("prop:columns");
1692
1721
  const cellsValue = block.get("prop:cells");
1693
- const rowEntries = mapEntries(rowsValue)
1722
+ let rowEntries = mapEntries(rowsValue)
1694
1723
  .map(([rowId, payload]) => ({
1695
1724
  rowId,
1696
1725
  order: payload && typeof payload === "object" && typeof payload.order === "string"
@@ -1698,7 +1727,7 @@ export function registerDocTools(server, gql, defaults) {
1698
1727
  : rowId,
1699
1728
  }))
1700
1729
  .sort((a, b) => a.order.localeCompare(b.order));
1701
- const columnEntries = mapEntries(columnsValue)
1730
+ let columnEntries = mapEntries(columnsValue)
1702
1731
  .map(([columnId, payload]) => ({
1703
1732
  columnId,
1704
1733
  order: payload && typeof payload === "object" && typeof payload.order === "string"
@@ -1706,19 +1735,56 @@ export function registerDocTools(server, gql, defaults) {
1706
1735
  : columnId,
1707
1736
  }))
1708
1737
  .sort((a, b) => a.order.localeCompare(b.order));
1738
+ let cells = new Map();
1709
1739
  if (rowEntries.length === 0 || columnEntries.length === 0) {
1710
- return null;
1711
- }
1712
- const cells = new Map();
1713
- for (const [cellKey, payload] of mapEntries(cellsValue)) {
1714
- if (payload instanceof Y.Map) {
1715
- cells.set(cellKey, richTextValueToString(payload.get("text")));
1716
- continue;
1740
+ // Fallback: AFFiNE self-hosted stores table props as flat dot-notation keys
1741
+ // directly on the block Y.Map instead of nested Y.Maps:
1742
+ // prop:rows.{rowId}.order
1743
+ // prop:columns.{colId}.order
1744
+ // prop:cells.{rowId}:{colId}.text (Y.Text)
1745
+ const flatRows = new Map(); // rowId -> order
1746
+ const flatColumns = new Map(); // colId -> order
1747
+ const flatCells = new Map(); // rowId:colId -> text
1748
+ block.forEach((value, key) => {
1749
+ const rowMatch = key.match(/^prop:rows\.([^.]+)\.order$/);
1750
+ if (rowMatch) {
1751
+ flatRows.set(rowMatch[1], typeof value === "string" ? value : rowMatch[1]);
1752
+ return;
1753
+ }
1754
+ const colMatch = key.match(/^prop:columns\.([^.]+)\.order$/);
1755
+ if (colMatch) {
1756
+ flatColumns.set(colMatch[1], typeof value === "string" ? value : colMatch[1]);
1757
+ return;
1758
+ }
1759
+ const cellMatch = key.match(/^prop:cells\.([^.]+:[^.]+)\.text$/);
1760
+ if (cellMatch) {
1761
+ flatCells.set(cellMatch[1], richTextValueToString(value));
1762
+ }
1763
+ });
1764
+ if (flatRows.size > 0 && flatColumns.size > 0) {
1765
+ rowEntries = Array.from(flatRows.entries())
1766
+ .map(([rowId, order]) => ({ rowId, order }))
1767
+ .sort((a, b) => a.order.localeCompare(b.order));
1768
+ columnEntries = Array.from(flatColumns.entries())
1769
+ .map(([columnId, order]) => ({ columnId, order }))
1770
+ .sort((a, b) => a.order.localeCompare(b.order));
1771
+ cells = flatCells;
1717
1772
  }
1718
- if (payload && typeof payload === "object" && "text" in payload) {
1719
- cells.set(cellKey, richTextValueToString(payload.text));
1773
+ }
1774
+ else {
1775
+ for (const [cellKey, payload] of mapEntries(cellsValue)) {
1776
+ if (payload instanceof Y.Map) {
1777
+ cells.set(cellKey, richTextValueToString(payload.get("text")));
1778
+ continue;
1779
+ }
1780
+ if (payload && typeof payload === "object" && "text" in payload) {
1781
+ cells.set(cellKey, richTextValueToString(payload.text));
1782
+ }
1720
1783
  }
1721
1784
  }
1785
+ if (rowEntries.length === 0 || columnEntries.length === 0) {
1786
+ return null;
1787
+ }
1722
1788
  const tableData = [];
1723
1789
  for (const { rowId } of rowEntries) {
1724
1790
  const row = [];
@@ -1986,6 +2052,8 @@ export function registerDocTools(server, gql, defaults) {
1986
2052
  const tagsByDocId = new Map();
1987
2053
  const titlesByDocId = new Map();
1988
2054
  let workspacePageCount = null;
2055
+ let workspacePageIds = null;
2056
+ const deletedDocIds = new Set();
1989
2057
  try {
1990
2058
  const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
1991
2059
  const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
@@ -1999,6 +2067,7 @@ export function registerDocTools(server, gql, defaults) {
1999
2067
  const meta = wsDoc.getMap("meta");
2000
2068
  const pages = getWorkspacePageEntries(meta);
2001
2069
  workspacePageCount = pages.length;
2070
+ workspacePageIds = new Set(pages.map(page => page.id));
2002
2071
  const { byId } = getWorkspaceTagOptionMaps(meta);
2003
2072
  for (const page of pages) {
2004
2073
  if (page.title) {
@@ -2008,6 +2077,20 @@ export function registerDocTools(server, gql, defaults) {
2008
2077
  tagsByDocId.set(page.id, resolveTagLabels(tagEntries, byId));
2009
2078
  }
2010
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
+ }
2011
2094
  }
2012
2095
  finally {
2013
2096
  socket.disconnect();
@@ -2032,19 +2115,22 @@ export function registerDocTools(server, gql, defaults) {
2032
2115
  };
2033
2116
  })
2034
2117
  : [];
2035
- // The workspace snapshot reflects deletions before GraphQL count metadata catches up.
2036
- // Clamp downward only so create/index lag does not inflate counts from partial snapshot state.
2118
+ const visibleEdges = deletedDocIds.size > 0
2119
+ ? mergedEdges.filter((edge) => !deletedDocIds.has(edge?.node?.id))
2120
+ : mergedEdges;
2037
2121
  const correctedTotalCount = typeof docs?.totalCount === "number" &&
2038
2122
  typeof workspacePageCount === "number" &&
2123
+ (deletedDocIds.size > 0 ||
2124
+ visibleEdges.length === workspacePageCount) &&
2039
2125
  workspacePageCount < docs.totalCount
2040
2126
  ? workspacePageCount
2041
2127
  : docs?.totalCount;
2042
2128
  const correctedPageInfo = docs?.pageInfo
2043
2129
  ? {
2044
2130
  ...docs.pageInfo,
2045
- endCursor: mergedEdges.length > 0 ? mergedEdges[mergedEdges.length - 1]?.cursor ?? null : null,
2131
+ endCursor: visibleEdges.length > 0 ? visibleEdges[visibleEdges.length - 1]?.cursor ?? null : null,
2046
2132
  hasNextPage: typeof correctedTotalCount === "number" && !parsed.after
2047
- ? (parsed.offset ?? 0) + mergedEdges.length < correctedTotalCount
2133
+ ? (parsed.offset ?? 0) + visibleEdges.length < correctedTotalCount
2048
2134
  : docs.pageInfo.hasNextPage,
2049
2135
  }
2050
2136
  : docs?.pageInfo;
@@ -2052,7 +2138,7 @@ export function registerDocTools(server, gql, defaults) {
2052
2138
  ...docs,
2053
2139
  totalCount: correctedTotalCount,
2054
2140
  pageInfo: correctedPageInfo,
2055
- edges: mergedEdges,
2141
+ edges: visibleEdges,
2056
2142
  };
2057
2143
  return text(mergedDocs);
2058
2144
  };
@@ -3957,12 +4043,14 @@ export function registerDocTools(server, gql, defaults) {
3957
4043
  cellsMap.set(rowBlockId, rowCells);
3958
4044
  return rowCells;
3959
4045
  }
3960
- function getDatabaseRowBlock(blocks, databaseBlockId, rowBlockId) {
4046
+ function getDatabaseRowBlock(blocks, dbBlock, databaseBlockId, rowBlockId) {
3961
4047
  const rowBlock = findBlockById(blocks, rowBlockId);
3962
4048
  if (!rowBlock) {
3963
4049
  throw new Error(`Row block '${rowBlockId}' not found`);
3964
4050
  }
3965
- if (rowBlock.get("sys:parent") !== databaseBlockId) {
4051
+ const parentId = rowBlock.get("sys:parent");
4052
+ const isDatabaseChild = getDatabaseRowIds(dbBlock).includes(rowBlockId);
4053
+ if (parentId !== databaseBlockId && !isDatabaseChild) {
3966
4054
  throw new Error(`Row block '${rowBlockId}' does not belong to database '${databaseBlockId}'`);
3967
4055
  }
3968
4056
  if (rowBlock.get("sys:flavour") !== "affine:paragraph") {
@@ -4191,8 +4279,13 @@ export function registerDocTools(server, gql, defaults) {
4191
4279
  rowBlock.set("sys:parent", parsed.databaseBlockId);
4192
4280
  rowBlock.set("sys:children", new Y.Array());
4193
4281
  rowBlock.set("prop:type", "text");
4194
- const titleValue = resolveDatabaseTitleValue(parsed.cells, ctx);
4195
- rowBlock.set("prop:text", makeText(String(titleValue)));
4282
+ if (parsed.linkedDocId) {
4283
+ rowBlock.set("prop:text", makeLinkedDocText(parsed.linkedDocId));
4284
+ }
4285
+ else {
4286
+ const titleValue = resolveDatabaseTitleValue(parsed.cells, ctx);
4287
+ rowBlock.set("prop:text", makeText(String(titleValue)));
4288
+ }
4196
4289
  ctx.blocks.set(rowBlockId, rowBlock);
4197
4290
  // Add row block to database's children
4198
4291
  const dbChildren = ensureChildrenArray(ctx.dbBlock);
@@ -4216,6 +4309,7 @@ export function registerDocTools(server, gql, defaults) {
4216
4309
  rowBlockId,
4217
4310
  databaseBlockId: parsed.databaseBlockId,
4218
4311
  cellCount: Object.keys(parsed.cells).length,
4312
+ linkedDocId: parsed.linkedDocId || null,
4219
4313
  });
4220
4314
  }
4221
4315
  finally {
@@ -4230,6 +4324,7 @@ export function registerDocTools(server, gql, defaults) {
4230
4324
  docId: DocId.describe("Document ID containing the database"),
4231
4325
  databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
4232
4326
  cells: z.record(z.unknown()).describe("Map of column name (or column ID) to cell value. For select columns, pass the display label (option auto-created if new)."),
4327
+ 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."),
4233
4328
  },
4234
4329
  }, addDatabaseRowHandler);
4235
4330
  const deleteDatabaseRowHandler = async (parsed) => {
@@ -4238,7 +4333,7 @@ export function registerDocTools(server, gql, defaults) {
4238
4333
  throw new Error("workspaceId is required");
4239
4334
  const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
4240
4335
  try {
4241
- const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, parsed.rowBlockId);
4336
+ const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
4242
4337
  const descendantBlockIds = collectDescendantBlockIds(ctx.blocks, [parsed.rowBlockId, ...childIdsFrom(rowBlock.get("sys:children"))]);
4243
4338
  const dbChildren = ensureChildrenArray(ctx.dbBlock);
4244
4339
  const rowIndex = indexOfChild(dbChildren, parsed.rowBlockId);
@@ -4292,7 +4387,7 @@ export function registerDocTools(server, gql, defaults) {
4292
4387
  : ctx.columnDefs;
4293
4388
  const requestedColumnIds = new Set(requestedColumns.map(col => col.id));
4294
4389
  const rows = requestedRows.map(rowBlockId => {
4295
- const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, rowBlockId);
4390
+ const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, rowBlockId);
4296
4391
  const title = readDatabaseRowTitle(rowBlock) || null;
4297
4392
  const rowCells = ctx.cellsMap.get(rowBlockId);
4298
4393
  const cells = {};
@@ -4314,6 +4409,7 @@ export function registerDocTools(server, gql, defaults) {
4314
4409
  return {
4315
4410
  rowBlockId,
4316
4411
  title,
4412
+ linkedDocId: readLinkedDocId(rowBlock),
4317
4413
  cells,
4318
4414
  };
4319
4415
  });
@@ -4375,7 +4471,7 @@ export function registerDocTools(server, gql, defaults) {
4375
4471
  throw new Error("workspaceId is required");
4376
4472
  const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
4377
4473
  try {
4378
- const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, parsed.rowBlockId);
4474
+ const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
4379
4475
  const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
4380
4476
  const col = findDatabaseColumn(parsed.column, ctx);
4381
4477
  if (!col) {
@@ -4386,7 +4482,10 @@ export function registerDocTools(server, gql, defaults) {
4386
4482
  else {
4387
4483
  writeDatabaseCellValue(rowCells, col, parsed.value, parsed.createOption ?? true);
4388
4484
  }
4389
- if (isTitleAliasKey(parsed.column) || (col && (col.type === "title" || isTitleAliasKey(col.name)))) {
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)))) {
4390
4489
  rowBlock.set("prop:text", makeText(String(parsed.value ?? "")));
4391
4490
  }
4392
4491
  const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
@@ -4413,6 +4512,7 @@ export function registerDocTools(server, gql, defaults) {
4413
4512
  column: z.string().min(1).describe("Column name or ID. Use `title` for the built-in row title."),
4414
4513
  value: z.unknown().describe("New cell value"),
4415
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."),
4416
4516
  },
4417
4517
  }, updateDatabaseCellHandler);
4418
4518
  const updateDatabaseRowHandler = async (parsed) => {
@@ -4421,7 +4521,7 @@ export function registerDocTools(server, gql, defaults) {
4421
4521
  throw new Error("workspaceId is required");
4422
4522
  const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
4423
4523
  try {
4424
- const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, parsed.rowBlockId);
4524
+ const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
4425
4525
  const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
4426
4526
  let titleValue = null;
4427
4527
  for (const [key, value] of Object.entries(parsed.cells)) {
@@ -4438,7 +4538,10 @@ export function registerDocTools(server, gql, defaults) {
4438
4538
  titleValue = String(value ?? "");
4439
4539
  }
4440
4540
  }
4441
- if (titleValue !== null) {
4541
+ if (parsed.linkedDocId) {
4542
+ rowBlock.set("prop:text", makeLinkedDocText(parsed.linkedDocId));
4543
+ }
4544
+ else if (titleValue !== null) {
4442
4545
  rowBlock.set("prop:text", makeText(titleValue));
4443
4546
  }
4444
4547
  const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
@@ -4463,6 +4566,7 @@ export function registerDocTools(server, gql, defaults) {
4463
4566
  rowBlockId: z.string().min(1).describe("Row paragraph block ID"),
4464
4567
  cells: z.record(z.unknown()).describe("Map of column name (or column ID) to new cell value. Use `title` for the built-in row title."),
4465
4568
  createOption: z.boolean().optional().describe("For select and multi-select columns, create the option label if it does not exist (default true)"),
4569
+ 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."),
4466
4570
  },
4467
4571
  }, updateDatabaseRowHandler);
4468
4572
  // ADD DATABASE COLUMN
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "affine-mcp-server",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Model Context Protocol server for AFFiNE - enables AI assistants to interact with AFFiNE workspaces, documents, and collaboration features.",
@@ -43,6 +43,7 @@
43
43
  "test:e2e": "bash tests/run-e2e.sh",
44
44
  "test:db-create": "node tests/test-database-creation.mjs",
45
45
  "test:db-cells": "node tests/test-database-cells.mjs",
46
+ "test:db-ui-rows": "node tests/test-database-ui-rows.mjs",
46
47
  "test:db-schema": "node tests/test-database-schema.mjs",
47
48
  "test:data-view": "node tests/test-data-view.mjs",
48
49
  "test:doc-discovery": "node tests/test-doc-discovery.mjs",