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 +38 -2
- package/dist/tools/docs.js +105 -21
- package/package.json +2 -1
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
|
-
[](https://github.com/dawncr0w/affine-mcp-server/releases)
|
|
6
6
|
[](https://github.com/modelcontextprotocol/typescript-sdk)
|
|
7
7
|
[](https://github.com/dawncr0w/affine-mcp-server/actions/workflows/ci.yml)
|
|
8
8
|
[](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.
|
|
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.
|
package/dist/tools/docs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
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
|
-
|
|
1719
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4215
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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",
|