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 +38 -2
- package/dist/tools/docs.js +130 -26
- 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 = [];
|
|
@@ -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
|
-
|
|
2036
|
-
|
|
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:
|
|
2131
|
+
endCursor: visibleEdges.length > 0 ? visibleEdges[visibleEdges.length - 1]?.cursor ?? null : null,
|
|
2046
2132
|
hasNextPage: typeof correctedTotalCount === "number" && !parsed.after
|
|
2047
|
-
? (parsed.offset ?? 0) +
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
4195
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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",
|