affine-mcp-server 1.11.2 → 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.2-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.2: Corrected stale deleted-document visibility in `list_docs` after `delete_doc`, completing the `v1.11.1` delete-metadata fix.
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 = [];
@@ -3977,12 +4043,14 @@ export function registerDocTools(server, gql, defaults) {
3977
4043
  cellsMap.set(rowBlockId, rowCells);
3978
4044
  return rowCells;
3979
4045
  }
3980
- function getDatabaseRowBlock(blocks, databaseBlockId, rowBlockId) {
4046
+ function getDatabaseRowBlock(blocks, dbBlock, databaseBlockId, rowBlockId) {
3981
4047
  const rowBlock = findBlockById(blocks, rowBlockId);
3982
4048
  if (!rowBlock) {
3983
4049
  throw new Error(`Row block '${rowBlockId}' not found`);
3984
4050
  }
3985
- 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) {
3986
4054
  throw new Error(`Row block '${rowBlockId}' does not belong to database '${databaseBlockId}'`);
3987
4055
  }
3988
4056
  if (rowBlock.get("sys:flavour") !== "affine:paragraph") {
@@ -4211,8 +4279,13 @@ export function registerDocTools(server, gql, defaults) {
4211
4279
  rowBlock.set("sys:parent", parsed.databaseBlockId);
4212
4280
  rowBlock.set("sys:children", new Y.Array());
4213
4281
  rowBlock.set("prop:type", "text");
4214
- const titleValue = resolveDatabaseTitleValue(parsed.cells, ctx);
4215
- 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
+ }
4216
4289
  ctx.blocks.set(rowBlockId, rowBlock);
4217
4290
  // Add row block to database's children
4218
4291
  const dbChildren = ensureChildrenArray(ctx.dbBlock);
@@ -4236,6 +4309,7 @@ export function registerDocTools(server, gql, defaults) {
4236
4309
  rowBlockId,
4237
4310
  databaseBlockId: parsed.databaseBlockId,
4238
4311
  cellCount: Object.keys(parsed.cells).length,
4312
+ linkedDocId: parsed.linkedDocId || null,
4239
4313
  });
4240
4314
  }
4241
4315
  finally {
@@ -4250,6 +4324,7 @@ export function registerDocTools(server, gql, defaults) {
4250
4324
  docId: DocId.describe("Document ID containing the database"),
4251
4325
  databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
4252
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."),
4253
4328
  },
4254
4329
  }, addDatabaseRowHandler);
4255
4330
  const deleteDatabaseRowHandler = async (parsed) => {
@@ -4258,7 +4333,7 @@ export function registerDocTools(server, gql, defaults) {
4258
4333
  throw new Error("workspaceId is required");
4259
4334
  const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
4260
4335
  try {
4261
- const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, parsed.rowBlockId);
4336
+ const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
4262
4337
  const descendantBlockIds = collectDescendantBlockIds(ctx.blocks, [parsed.rowBlockId, ...childIdsFrom(rowBlock.get("sys:children"))]);
4263
4338
  const dbChildren = ensureChildrenArray(ctx.dbBlock);
4264
4339
  const rowIndex = indexOfChild(dbChildren, parsed.rowBlockId);
@@ -4312,7 +4387,7 @@ export function registerDocTools(server, gql, defaults) {
4312
4387
  : ctx.columnDefs;
4313
4388
  const requestedColumnIds = new Set(requestedColumns.map(col => col.id));
4314
4389
  const rows = requestedRows.map(rowBlockId => {
4315
- const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, rowBlockId);
4390
+ const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, rowBlockId);
4316
4391
  const title = readDatabaseRowTitle(rowBlock) || null;
4317
4392
  const rowCells = ctx.cellsMap.get(rowBlockId);
4318
4393
  const cells = {};
@@ -4334,6 +4409,7 @@ export function registerDocTools(server, gql, defaults) {
4334
4409
  return {
4335
4410
  rowBlockId,
4336
4411
  title,
4412
+ linkedDocId: readLinkedDocId(rowBlock),
4337
4413
  cells,
4338
4414
  };
4339
4415
  });
@@ -4395,7 +4471,7 @@ export function registerDocTools(server, gql, defaults) {
4395
4471
  throw new Error("workspaceId is required");
4396
4472
  const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
4397
4473
  try {
4398
- const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, parsed.rowBlockId);
4474
+ const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
4399
4475
  const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
4400
4476
  const col = findDatabaseColumn(parsed.column, ctx);
4401
4477
  if (!col) {
@@ -4406,7 +4482,10 @@ export function registerDocTools(server, gql, defaults) {
4406
4482
  else {
4407
4483
  writeDatabaseCellValue(rowCells, col, parsed.value, parsed.createOption ?? true);
4408
4484
  }
4409
- 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)))) {
4410
4489
  rowBlock.set("prop:text", makeText(String(parsed.value ?? "")));
4411
4490
  }
4412
4491
  const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
@@ -4433,6 +4512,7 @@ export function registerDocTools(server, gql, defaults) {
4433
4512
  column: z.string().min(1).describe("Column name or ID. Use `title` for the built-in row title."),
4434
4513
  value: z.unknown().describe("New cell value"),
4435
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."),
4436
4516
  },
4437
4517
  }, updateDatabaseCellHandler);
4438
4518
  const updateDatabaseRowHandler = async (parsed) => {
@@ -4441,7 +4521,7 @@ export function registerDocTools(server, gql, defaults) {
4441
4521
  throw new Error("workspaceId is required");
4442
4522
  const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
4443
4523
  try {
4444
- const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, parsed.rowBlockId);
4524
+ const rowBlock = getDatabaseRowBlock(ctx.blocks, ctx.dbBlock, parsed.databaseBlockId, parsed.rowBlockId);
4445
4525
  const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
4446
4526
  let titleValue = null;
4447
4527
  for (const [key, value] of Object.entries(parsed.cells)) {
@@ -4458,7 +4538,10 @@ export function registerDocTools(server, gql, defaults) {
4458
4538
  titleValue = String(value ?? "");
4459
4539
  }
4460
4540
  }
4461
- if (titleValue !== null) {
4541
+ if (parsed.linkedDocId) {
4542
+ rowBlock.set("prop:text", makeLinkedDocText(parsed.linkedDocId));
4543
+ }
4544
+ else if (titleValue !== null) {
4462
4545
  rowBlock.set("prop:text", makeText(titleValue));
4463
4546
  }
4464
4547
  const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
@@ -4483,6 +4566,7 @@ export function registerDocTools(server, gql, defaults) {
4483
4566
  rowBlockId: z.string().min(1).describe("Row paragraph block ID"),
4484
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."),
4485
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."),
4486
4570
  },
4487
4571
  }, updateDatabaseRowHandler);
4488
4572
  // ADD DATABASE COLUMN
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "affine-mcp-server",
3
- "version": "1.11.2",
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",