affine-mcp-server 1.7.2 → 1.8.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 +17 -6
- package/dist/index.js +7 -2
- package/dist/tools/docs.js +424 -122
- package/package.json +3 -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)
|
|
@@ -16,16 +16,16 @@ A Model Context Protocol (MCP) server that integrates with AFFiNE (self‑hosted
|
|
|
16
16
|
- Purpose: Manage AFFiNE workspaces and documents through MCP
|
|
17
17
|
- Transport: stdio (default) and optional HTTP (`/mcp`) for remote MCP deployments
|
|
18
18
|
- Auth: Token, Cookie, or Email/Password (priority order)
|
|
19
|
-
- Tools:
|
|
19
|
+
- Tools: 46 focused tools with WebSocket-based document editing
|
|
20
20
|
- Status: Active
|
|
21
21
|
|
|
22
|
-
> New in v1.
|
|
22
|
+
> New in v1.8.0: Added database cell read/write tools, fixed Kanban row title persistence, and added CLI version commands.
|
|
23
23
|
|
|
24
24
|
## Features
|
|
25
25
|
|
|
26
26
|
- Workspace: create (with initial doc), read, update, delete
|
|
27
27
|
- Documents: list/get/read/publish/revoke + create/append/replace/delete + markdown import/export + tags (WebSocket‑based)
|
|
28
|
-
- Database workflows: create database blocks,
|
|
28
|
+
- Database workflows: create database blocks, add columns and rows, and read or update cell values via MCP tools
|
|
29
29
|
- Comments: full CRUD and resolve
|
|
30
30
|
- Version History: list
|
|
31
31
|
- Users & Tokens: current user, sign in, profile/settings, and personal access tokens
|
|
@@ -95,6 +95,7 @@ The MCP server will use these credentials automatically.
|
|
|
95
95
|
Other CLI commands:
|
|
96
96
|
- `affine-mcp status` — show current config and test connection
|
|
97
97
|
- `affine-mcp logout` — remove stored credentials
|
|
98
|
+
- `affine-mcp --version` / `-v` / `version` — print the installed CLI version and exit
|
|
98
99
|
|
|
99
100
|
### Environment variables
|
|
100
101
|
|
|
@@ -326,7 +327,10 @@ Endpoints currently available:
|
|
|
326
327
|
- `append_paragraph` – append a paragraph block (WebSocket)
|
|
327
328
|
- `append_block` – append canonical block types (text/list/code/media/embed/database/edgeless) with strict validation and placement control (`data_view` currently falls back to database)
|
|
328
329
|
- `add_database_column` – add a column to a database block (`rich-text`, `select`, `multi-select`, `number`, `checkbox`, `link`, `date`)
|
|
329
|
-
- `add_database_row` – add a row to a database block with values mapped by column name/ID
|
|
330
|
+
- `add_database_row` – add a row to a database block with values mapped by column name/ID (`title` / `Title` updates the built-in row title)
|
|
331
|
+
- `read_database_cells` – read row titles plus decoded database cell values with optional row / column filters
|
|
332
|
+
- `update_database_cell` – update a single database cell or the built-in row title (`createOption` defaults to `true` for select fields)
|
|
333
|
+
- `update_database_row` – batch update multiple cells on a database row (`createOption` defaults to `true` for select fields)
|
|
330
334
|
- `append_markdown` – append markdown content to an existing document
|
|
331
335
|
- `replace_doc_with_markdown` – replace the main note content with markdown content
|
|
332
336
|
- `delete_doc` – delete a document (WebSocket)
|
|
@@ -374,7 +378,7 @@ npm run pack:check
|
|
|
374
378
|
- CI validates that `registerTool(...)` declarations match the manifest exactly.
|
|
375
379
|
- For full tool-surface verification, run `npm run test:comprehensive`.
|
|
376
380
|
- For full environment verification, run `npm run test:e2e` (Docker + MCP + Playwright).
|
|
377
|
-
- Additional focused runners: `npm run test:db-create`, `npm run test:bearer`, `npm run test:playwright`.
|
|
381
|
+
- Additional focused runners: `npm run test:db-create`, `npm run test:db-cells`, `npm run test:bearer`, `npm run test:cli-version`, `npm run test:playwright`.
|
|
378
382
|
|
|
379
383
|
## Troubleshooting
|
|
380
384
|
|
|
@@ -409,6 +413,13 @@ Workspace visibility
|
|
|
409
413
|
|
|
410
414
|
## Version History
|
|
411
415
|
|
|
416
|
+
### 1.8.0 (2026‑03‑09)
|
|
417
|
+
- Added `read_database_cells`, `update_database_cell`, and `update_database_row` for database cell-level workflows
|
|
418
|
+
- Fixed `add_database_row` so `title` / `Title` persists to the Kanban card header text
|
|
419
|
+
- Added CLI version commands: `affine-mcp --version`, `affine-mcp -v`, and `affine-mcp version`
|
|
420
|
+
- Added focused regression runners for database cells and CLI version support
|
|
421
|
+
- Verified release gates with `npm run ci`, `npm run test:cli-version`, and live `npm run test:db-cells`
|
|
422
|
+
|
|
412
423
|
### 1.7.2 (2026‑03‑04)
|
|
413
424
|
- Fixed MCP tag persistence to use AFFiNE canonical tag option IDs so tags are visible in Web/App UI
|
|
414
425
|
- Added backward-compatible tag normalization for legacy string tag entries
|
package/dist/index.js
CHANGED
|
@@ -15,8 +15,13 @@ import { loginWithPassword } from "./auth.js";
|
|
|
15
15
|
import { registerAuthTools } from "./tools/auth.js";
|
|
16
16
|
import { runCli } from "./cli.js";
|
|
17
17
|
import { startHttpMcpServer } from "./sse.js";
|
|
18
|
-
// CLI
|
|
19
|
-
const
|
|
18
|
+
// CLI commands: affine-mcp login|status|logout|version
|
|
19
|
+
const rawArgs = process.argv.slice(2);
|
|
20
|
+
const subcommand = rawArgs[0] === "--" ? rawArgs[1] : rawArgs[0];
|
|
21
|
+
if (subcommand === "--version" || subcommand === "-v" || subcommand === "version") {
|
|
22
|
+
console.log(VERSION);
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
20
25
|
if (subcommand && await runCli(subcommand)) {
|
|
21
26
|
process.exit(0);
|
|
22
27
|
}
|
package/dist/tools/docs.js
CHANGED
|
@@ -2723,7 +2723,6 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2723
2723
|
description: 'Delete a document and remove from workspace list',
|
|
2724
2724
|
inputSchema: { workspaceId: z.string().optional(), docId: z.string() },
|
|
2725
2725
|
}, deleteDocHandler);
|
|
2726
|
-
// ── helpers for database select columns ──
|
|
2727
2726
|
/** Read column definitions including select options from a database block */
|
|
2728
2727
|
function readColumnDefs(dbBlock) {
|
|
2729
2728
|
const columnsRaw = dbBlock.get("prop:columns");
|
|
@@ -2761,17 +2760,111 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2761
2760
|
});
|
|
2762
2761
|
return defs;
|
|
2763
2762
|
}
|
|
2763
|
+
function isTitleAliasKey(value) {
|
|
2764
|
+
return value.trim().toLowerCase() === "title";
|
|
2765
|
+
}
|
|
2766
|
+
function buildDatabaseColumnLookup(columnDefs) {
|
|
2767
|
+
const colById = new Map();
|
|
2768
|
+
const colByName = new Map();
|
|
2769
|
+
const colByNameLower = new Map();
|
|
2770
|
+
let titleCol = null;
|
|
2771
|
+
for (const col of columnDefs) {
|
|
2772
|
+
colById.set(col.id, col);
|
|
2773
|
+
if (col.name) {
|
|
2774
|
+
colByName.set(col.name, col);
|
|
2775
|
+
colByNameLower.set(col.name.trim().toLowerCase(), col);
|
|
2776
|
+
}
|
|
2777
|
+
if (!titleCol && col.type === "title") {
|
|
2778
|
+
titleCol = col;
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
return { columnDefs, colById, colByName, colByNameLower, titleCol };
|
|
2782
|
+
}
|
|
2783
|
+
function findDatabaseColumn(key, lookup) {
|
|
2784
|
+
return lookup.colByName.get(key)
|
|
2785
|
+
|| lookup.colById.get(key)
|
|
2786
|
+
|| lookup.colByNameLower.get(key.trim().toLowerCase())
|
|
2787
|
+
|| null;
|
|
2788
|
+
}
|
|
2789
|
+
function availableDatabaseColumns(lookup) {
|
|
2790
|
+
return ["title", ...lookup.columnDefs.map(col => col.name || col.id)].join(", ");
|
|
2791
|
+
}
|
|
2792
|
+
function getDatabaseRowIds(dbBlock) {
|
|
2793
|
+
return childIdsFrom(dbBlock.get("sys:children"));
|
|
2794
|
+
}
|
|
2795
|
+
function readDatabaseRowTitle(rowBlock) {
|
|
2796
|
+
return asText(rowBlock.get("prop:text"));
|
|
2797
|
+
}
|
|
2798
|
+
function resolveDatabaseTitleValue(cells, lookup) {
|
|
2799
|
+
if (lookup.titleCol) {
|
|
2800
|
+
const value = cells[lookup.titleCol.name] ?? cells[lookup.titleCol.id];
|
|
2801
|
+
if (value !== undefined) {
|
|
2802
|
+
return String(value ?? "");
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
for (const [key, value] of Object.entries(cells)) {
|
|
2806
|
+
if (isTitleAliasKey(key)) {
|
|
2807
|
+
return String(value ?? "");
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
const namedTitleColumn = lookup.colByNameLower.get("title");
|
|
2811
|
+
if (namedTitleColumn) {
|
|
2812
|
+
const value = cells[namedTitleColumn.name] ?? cells[namedTitleColumn.id];
|
|
2813
|
+
if (value !== undefined) {
|
|
2814
|
+
return String(value ?? "");
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
return "";
|
|
2818
|
+
}
|
|
2819
|
+
function ensureDatabaseRowCells(cellsMap, rowBlockId) {
|
|
2820
|
+
const existing = cellsMap.get(rowBlockId);
|
|
2821
|
+
if (existing instanceof Y.Map) {
|
|
2822
|
+
return existing;
|
|
2823
|
+
}
|
|
2824
|
+
const rowCells = new Y.Map();
|
|
2825
|
+
cellsMap.set(rowBlockId, rowCells);
|
|
2826
|
+
return rowCells;
|
|
2827
|
+
}
|
|
2828
|
+
function getDatabaseRowBlock(blocks, databaseBlockId, rowBlockId) {
|
|
2829
|
+
const rowBlock = findBlockById(blocks, rowBlockId);
|
|
2830
|
+
if (!rowBlock) {
|
|
2831
|
+
throw new Error(`Row block '${rowBlockId}' not found`);
|
|
2832
|
+
}
|
|
2833
|
+
if (rowBlock.get("sys:parent") !== databaseBlockId) {
|
|
2834
|
+
throw new Error(`Row block '${rowBlockId}' does not belong to database '${databaseBlockId}'`);
|
|
2835
|
+
}
|
|
2836
|
+
if (rowBlock.get("sys:flavour") !== "affine:paragraph") {
|
|
2837
|
+
throw new Error(`Row block '${rowBlockId}' is not a database row paragraph`);
|
|
2838
|
+
}
|
|
2839
|
+
return rowBlock;
|
|
2840
|
+
}
|
|
2841
|
+
function databaseArrayValues(value) {
|
|
2842
|
+
if (value instanceof Y.Array) {
|
|
2843
|
+
const entries = [];
|
|
2844
|
+
value.forEach(entry => {
|
|
2845
|
+
entries.push(entry);
|
|
2846
|
+
});
|
|
2847
|
+
return entries;
|
|
2848
|
+
}
|
|
2849
|
+
if (Array.isArray(value)) {
|
|
2850
|
+
return value;
|
|
2851
|
+
}
|
|
2852
|
+
return [];
|
|
2853
|
+
}
|
|
2764
2854
|
const SELECT_COLORS = [
|
|
2765
2855
|
"var(--affine-tag-blue)", "var(--affine-tag-green)", "var(--affine-tag-red)",
|
|
2766
2856
|
"var(--affine-tag-orange)", "var(--affine-tag-purple)", "var(--affine-tag-yellow)",
|
|
2767
2857
|
"var(--affine-tag-teal)", "var(--affine-tag-pink)", "var(--affine-tag-gray)",
|
|
2768
2858
|
];
|
|
2769
2859
|
/** Find or create a select option for a column, mutating the column's data in place */
|
|
2770
|
-
function resolveSelectOptionId(col, valueText) {
|
|
2860
|
+
function resolveSelectOptionId(col, valueText, createOption = true) {
|
|
2771
2861
|
// Try exact match first
|
|
2772
2862
|
const existing = col.options.find(o => o.value === valueText);
|
|
2773
2863
|
if (existing)
|
|
2774
2864
|
return existing.id;
|
|
2865
|
+
if (!createOption) {
|
|
2866
|
+
throw new Error(`Column "${col.name}": option "${valueText}" not found`);
|
|
2867
|
+
}
|
|
2775
2868
|
// Create new option
|
|
2776
2869
|
const newId = generateId();
|
|
2777
2870
|
const colorIdx = col.options.length % SELECT_COLORS.length;
|
|
@@ -2798,43 +2891,172 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2798
2891
|
}
|
|
2799
2892
|
return newId;
|
|
2800
2893
|
}
|
|
2894
|
+
function decodeDatabaseCellValue(col, cellEntry) {
|
|
2895
|
+
const rawValue = cellEntry instanceof Y.Map ? cellEntry.get("value") : cellEntry?.value;
|
|
2896
|
+
const base = {
|
|
2897
|
+
columnId: col.id,
|
|
2898
|
+
type: col.type,
|
|
2899
|
+
};
|
|
2900
|
+
switch (col.type) {
|
|
2901
|
+
case "rich-text":
|
|
2902
|
+
case "title":
|
|
2903
|
+
return { ...base, value: richTextValueToString(rawValue) || null };
|
|
2904
|
+
case "select": {
|
|
2905
|
+
const optionId = asStringOrNull(rawValue);
|
|
2906
|
+
const option = col.options.find(entry => entry.id === optionId) || null;
|
|
2907
|
+
return {
|
|
2908
|
+
...base,
|
|
2909
|
+
value: option?.value ?? optionId ?? null,
|
|
2910
|
+
optionId: optionId ?? null,
|
|
2911
|
+
};
|
|
2912
|
+
}
|
|
2913
|
+
case "multi-select": {
|
|
2914
|
+
const optionIds = databaseArrayValues(rawValue).map(entry => String(entry));
|
|
2915
|
+
const values = optionIds.map(optionId => col.options.find(entry => entry.id === optionId)?.value ?? optionId);
|
|
2916
|
+
return {
|
|
2917
|
+
...base,
|
|
2918
|
+
value: values,
|
|
2919
|
+
optionIds,
|
|
2920
|
+
};
|
|
2921
|
+
}
|
|
2922
|
+
case "number": {
|
|
2923
|
+
const numericValue = typeof rawValue === "number" ? rawValue : Number(rawValue);
|
|
2924
|
+
return {
|
|
2925
|
+
...base,
|
|
2926
|
+
value: Number.isFinite(numericValue) ? numericValue : null,
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
case "checkbox":
|
|
2930
|
+
return { ...base, value: typeof rawValue === "boolean" ? rawValue : !!rawValue };
|
|
2931
|
+
case "date": {
|
|
2932
|
+
const numericValue = typeof rawValue === "number" ? rawValue : Number(rawValue);
|
|
2933
|
+
return {
|
|
2934
|
+
...base,
|
|
2935
|
+
value: Number.isFinite(numericValue) ? numericValue : null,
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2938
|
+
case "link":
|
|
2939
|
+
return { ...base, value: rawValue == null ? null : String(rawValue) };
|
|
2940
|
+
default:
|
|
2941
|
+
return {
|
|
2942
|
+
...base,
|
|
2943
|
+
value: typeof rawValue === "string" || rawValue instanceof Y.Text || Array.isArray(rawValue)
|
|
2944
|
+
? richTextValueToString(rawValue)
|
|
2945
|
+
: rawValue ?? null,
|
|
2946
|
+
};
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
function writeDatabaseCellValue(rowCells, col, value, createOption) {
|
|
2950
|
+
const cellValue = new Y.Map();
|
|
2951
|
+
cellValue.set("columnId", col.id);
|
|
2952
|
+
switch (col.type) {
|
|
2953
|
+
case "rich-text":
|
|
2954
|
+
case "title":
|
|
2955
|
+
cellValue.set("value", makeText(String(value ?? "")));
|
|
2956
|
+
break;
|
|
2957
|
+
case "number": {
|
|
2958
|
+
const num = Number(value);
|
|
2959
|
+
if (Number.isNaN(num)) {
|
|
2960
|
+
throw new Error(`Column "${col.name}": expected a number, got ${JSON.stringify(value)}`);
|
|
2961
|
+
}
|
|
2962
|
+
cellValue.set("value", num);
|
|
2963
|
+
break;
|
|
2964
|
+
}
|
|
2965
|
+
case "checkbox": {
|
|
2966
|
+
let bool;
|
|
2967
|
+
if (typeof value === "boolean") {
|
|
2968
|
+
bool = value;
|
|
2969
|
+
}
|
|
2970
|
+
else if (typeof value === "string") {
|
|
2971
|
+
const lower = value.toLowerCase().trim();
|
|
2972
|
+
bool = lower === "true" || lower === "1" || lower === "yes";
|
|
2973
|
+
}
|
|
2974
|
+
else {
|
|
2975
|
+
bool = !!value;
|
|
2976
|
+
}
|
|
2977
|
+
cellValue.set("value", bool);
|
|
2978
|
+
break;
|
|
2979
|
+
}
|
|
2980
|
+
case "select":
|
|
2981
|
+
cellValue.set("value", resolveSelectOptionId(col, String(value ?? ""), createOption));
|
|
2982
|
+
break;
|
|
2983
|
+
case "multi-select": {
|
|
2984
|
+
const labels = Array.isArray(value) ? value.map(String) : [String(value ?? "")];
|
|
2985
|
+
const optionIds = new Y.Array();
|
|
2986
|
+
optionIds.push(labels.map(label => resolveSelectOptionId(col, label, createOption)));
|
|
2987
|
+
cellValue.set("value", optionIds);
|
|
2988
|
+
break;
|
|
2989
|
+
}
|
|
2990
|
+
case "date": {
|
|
2991
|
+
const numericValue = typeof value === "number"
|
|
2992
|
+
? value
|
|
2993
|
+
: Number.isNaN(Number(value)) ? Date.parse(String(value)) : Number(value);
|
|
2994
|
+
if (!Number.isFinite(numericValue)) {
|
|
2995
|
+
throw new Error(`Column "${col.name}": expected a timestamp-compatible value, got ${JSON.stringify(value)}`);
|
|
2996
|
+
}
|
|
2997
|
+
cellValue.set("value", numericValue);
|
|
2998
|
+
break;
|
|
2999
|
+
}
|
|
3000
|
+
case "link":
|
|
3001
|
+
cellValue.set("value", String(value ?? ""));
|
|
3002
|
+
break;
|
|
3003
|
+
default:
|
|
3004
|
+
if (typeof value === "string") {
|
|
3005
|
+
cellValue.set("value", makeText(value));
|
|
3006
|
+
}
|
|
3007
|
+
else {
|
|
3008
|
+
cellValue.set("value", value);
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
rowCells.set(col.id, cellValue);
|
|
3012
|
+
}
|
|
3013
|
+
async function loadDatabaseDocContext(workspaceId, docId, databaseBlockId) {
|
|
3014
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3015
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3016
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3017
|
+
await joinWorkspace(socket, workspaceId);
|
|
3018
|
+
const doc = new Y.Doc();
|
|
3019
|
+
const snapshot = await loadDoc(socket, workspaceId, docId);
|
|
3020
|
+
if (!snapshot.missing) {
|
|
3021
|
+
socket.disconnect();
|
|
3022
|
+
throw new Error("Document not found");
|
|
3023
|
+
}
|
|
3024
|
+
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
3025
|
+
const prevSV = Y.encodeStateVector(doc);
|
|
3026
|
+
const blocks = doc.getMap("blocks");
|
|
3027
|
+
const dbBlock = findBlockById(blocks, databaseBlockId);
|
|
3028
|
+
if (!dbBlock) {
|
|
3029
|
+
socket.disconnect();
|
|
3030
|
+
throw new Error(`Database block '${databaseBlockId}' not found`);
|
|
3031
|
+
}
|
|
3032
|
+
const dbFlavour = dbBlock.get("sys:flavour");
|
|
3033
|
+
if (dbFlavour !== "affine:database") {
|
|
3034
|
+
socket.disconnect();
|
|
3035
|
+
throw new Error(`Block '${databaseBlockId}' is not a database (flavour: ${dbFlavour})`);
|
|
3036
|
+
}
|
|
3037
|
+
const cellsMap = dbBlock.get("prop:cells");
|
|
3038
|
+
if (!(cellsMap instanceof Y.Map)) {
|
|
3039
|
+
socket.disconnect();
|
|
3040
|
+
throw new Error("Database block has no cells map");
|
|
3041
|
+
}
|
|
3042
|
+
const lookup = buildDatabaseColumnLookup(readColumnDefs(dbBlock));
|
|
3043
|
+
return {
|
|
3044
|
+
socket,
|
|
3045
|
+
doc,
|
|
3046
|
+
prevSV,
|
|
3047
|
+
blocks,
|
|
3048
|
+
dbBlock,
|
|
3049
|
+
cellsMap,
|
|
3050
|
+
...lookup,
|
|
3051
|
+
};
|
|
3052
|
+
}
|
|
2801
3053
|
// ADD DATABASE ROW
|
|
2802
3054
|
const addDatabaseRowHandler = async (parsed) => {
|
|
2803
3055
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
2804
3056
|
if (!workspaceId)
|
|
2805
3057
|
throw new Error("workspaceId is required");
|
|
2806
|
-
const
|
|
2807
|
-
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
2808
|
-
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3058
|
+
const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
|
|
2809
3059
|
try {
|
|
2810
|
-
await joinWorkspace(socket, workspaceId);
|
|
2811
|
-
const doc = new Y.Doc();
|
|
2812
|
-
const snapshot = await loadDoc(socket, workspaceId, parsed.docId);
|
|
2813
|
-
if (!snapshot.missing)
|
|
2814
|
-
throw new Error("Document not found");
|
|
2815
|
-
Y.applyUpdate(doc, Buffer.from(snapshot.missing, "base64"));
|
|
2816
|
-
const prevSV = Y.encodeStateVector(doc);
|
|
2817
|
-
const blocks = doc.getMap("blocks");
|
|
2818
|
-
// Find the database block
|
|
2819
|
-
const dbBlock = findBlockById(blocks, parsed.databaseBlockId);
|
|
2820
|
-
if (!dbBlock)
|
|
2821
|
-
throw new Error(`Database block '${parsed.databaseBlockId}' not found`);
|
|
2822
|
-
const dbFlavour = dbBlock.get("sys:flavour");
|
|
2823
|
-
if (dbFlavour !== "affine:database") {
|
|
2824
|
-
throw new Error(`Block '${parsed.databaseBlockId}' is not a database (flavour: ${dbFlavour})`);
|
|
2825
|
-
}
|
|
2826
|
-
// Read column definitions with select options
|
|
2827
|
-
const columnDefs = readColumnDefs(dbBlock);
|
|
2828
|
-
// Build lookups
|
|
2829
|
-
const colByName = new Map();
|
|
2830
|
-
const colById = new Map();
|
|
2831
|
-
for (const col of columnDefs) {
|
|
2832
|
-
if (col.name)
|
|
2833
|
-
colByName.set(col.name, col);
|
|
2834
|
-
colById.set(col.id, col);
|
|
2835
|
-
}
|
|
2836
|
-
// Identify the title column (first column, or type === "title")
|
|
2837
|
-
const titleCol = columnDefs.find(c => c.type === "title") || null;
|
|
2838
3060
|
// Create a new paragraph block as the row child of the database
|
|
2839
3061
|
const rowBlockId = generateId();
|
|
2840
3062
|
const rowBlock = new Y.Map();
|
|
@@ -2842,104 +3064,26 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2842
3064
|
rowBlock.set("sys:parent", parsed.databaseBlockId);
|
|
2843
3065
|
rowBlock.set("sys:children", new Y.Array());
|
|
2844
3066
|
rowBlock.set("prop:type", "text");
|
|
2845
|
-
|
|
2846
|
-
const titleValue = titleCol ? parsed.cells[titleCol.name] ?? parsed.cells[titleCol.id] ?? "" : "";
|
|
3067
|
+
const titleValue = resolveDatabaseTitleValue(parsed.cells, ctx);
|
|
2847
3068
|
rowBlock.set("prop:text", makeText(String(titleValue)));
|
|
2848
|
-
blocks.set(rowBlockId, rowBlock);
|
|
3069
|
+
ctx.blocks.set(rowBlockId, rowBlock);
|
|
2849
3070
|
// Add row block to database's children
|
|
2850
|
-
const dbChildren = ensureChildrenArray(dbBlock);
|
|
3071
|
+
const dbChildren = ensureChildrenArray(ctx.dbBlock);
|
|
2851
3072
|
dbChildren.push([rowBlockId]);
|
|
2852
|
-
// Populate cells map on the database block
|
|
2853
|
-
const cellsMap = dbBlock.get("prop:cells");
|
|
2854
|
-
if (!(cellsMap instanceof Y.Map)) {
|
|
2855
|
-
throw new Error("Database block has no cells map");
|
|
2856
|
-
}
|
|
2857
3073
|
// Create row cell map
|
|
2858
|
-
const rowCells =
|
|
3074
|
+
const rowCells = ensureDatabaseRowCells(ctx.cellsMap, rowBlockId);
|
|
2859
3075
|
for (const [key, value] of Object.entries(parsed.cells)) {
|
|
2860
|
-
|
|
2861
|
-
const col = colByName.get(key) || colById.get(key);
|
|
3076
|
+
const col = findDatabaseColumn(key, ctx);
|
|
2862
3077
|
if (!col) {
|
|
2863
|
-
|
|
2864
|
-
}
|
|
2865
|
-
// Skip the title column — already stored on the paragraph block
|
|
2866
|
-
if (titleCol && col.id === titleCol.id)
|
|
2867
|
-
continue;
|
|
2868
|
-
// Create cell value based on column type
|
|
2869
|
-
const cellValue = new Y.Map();
|
|
2870
|
-
cellValue.set("columnId", col.id);
|
|
2871
|
-
switch (col.type) {
|
|
2872
|
-
case "rich-text": {
|
|
2873
|
-
const yText = makeText(String(value ?? ""));
|
|
2874
|
-
cellValue.set("value", yText);
|
|
2875
|
-
break;
|
|
2876
|
-
}
|
|
2877
|
-
case "title": {
|
|
2878
|
-
// Handled above on the paragraph block; skip
|
|
3078
|
+
if (isTitleAliasKey(key)) {
|
|
2879
3079
|
continue;
|
|
2880
3080
|
}
|
|
2881
|
-
|
|
2882
|
-
const num = Number(value);
|
|
2883
|
-
if (Number.isNaN(num)) {
|
|
2884
|
-
throw new Error(`Column "${col.name}": expected a number, got ${JSON.stringify(value)}`);
|
|
2885
|
-
}
|
|
2886
|
-
cellValue.set("value", num);
|
|
2887
|
-
break;
|
|
2888
|
-
}
|
|
2889
|
-
case "checkbox": {
|
|
2890
|
-
let bool;
|
|
2891
|
-
if (typeof value === "boolean") {
|
|
2892
|
-
bool = value;
|
|
2893
|
-
}
|
|
2894
|
-
else if (typeof value === "string") {
|
|
2895
|
-
const lower = value.toLowerCase().trim();
|
|
2896
|
-
bool = lower === "true" || lower === "1" || lower === "yes";
|
|
2897
|
-
}
|
|
2898
|
-
else {
|
|
2899
|
-
bool = !!value;
|
|
2900
|
-
}
|
|
2901
|
-
cellValue.set("value", bool);
|
|
2902
|
-
break;
|
|
2903
|
-
}
|
|
2904
|
-
case "select": {
|
|
2905
|
-
// Resolve option ID by label text; auto-create if needed
|
|
2906
|
-
const optionId = resolveSelectOptionId(col, String(value ?? ""));
|
|
2907
|
-
cellValue.set("value", optionId);
|
|
2908
|
-
break;
|
|
2909
|
-
}
|
|
2910
|
-
case "multi-select": {
|
|
2911
|
-
const labels = Array.isArray(value) ? value.map(String) : [String(value ?? "")];
|
|
2912
|
-
const ids = labels.map(lbl => resolveSelectOptionId(col, lbl));
|
|
2913
|
-
cellValue.set("value", ids);
|
|
2914
|
-
break;
|
|
2915
|
-
}
|
|
2916
|
-
case "date": {
|
|
2917
|
-
const ts = Number(value);
|
|
2918
|
-
if (Number.isNaN(ts)) {
|
|
2919
|
-
throw new Error(`Column "${col.name}": expected a timestamp number, got ${JSON.stringify(value)}`);
|
|
2920
|
-
}
|
|
2921
|
-
cellValue.set("value", ts);
|
|
2922
|
-
break;
|
|
2923
|
-
}
|
|
2924
|
-
case "link": {
|
|
2925
|
-
cellValue.set("value", String(value ?? ""));
|
|
2926
|
-
break;
|
|
2927
|
-
}
|
|
2928
|
-
default: {
|
|
2929
|
-
// Fallback: store as rich-text
|
|
2930
|
-
if (typeof value === "string") {
|
|
2931
|
-
cellValue.set("value", makeText(value));
|
|
2932
|
-
}
|
|
2933
|
-
else {
|
|
2934
|
-
cellValue.set("value", value);
|
|
2935
|
-
}
|
|
2936
|
-
}
|
|
3081
|
+
throw new Error(`Column '${key}' not found. Available columns: ${availableDatabaseColumns(ctx)}`);
|
|
2937
3082
|
}
|
|
2938
|
-
rowCells
|
|
3083
|
+
writeDatabaseCellValue(rowCells, col, value, true);
|
|
2939
3084
|
}
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
await pushDocUpdate(socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
3085
|
+
const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
|
|
3086
|
+
await pushDocUpdate(ctx.socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
2943
3087
|
return text({
|
|
2944
3088
|
added: true,
|
|
2945
3089
|
rowBlockId,
|
|
@@ -2948,7 +3092,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2948
3092
|
});
|
|
2949
3093
|
}
|
|
2950
3094
|
finally {
|
|
2951
|
-
socket.disconnect();
|
|
3095
|
+
ctx.socket.disconnect();
|
|
2952
3096
|
}
|
|
2953
3097
|
};
|
|
2954
3098
|
server.registerTool("add_database_row", {
|
|
@@ -2961,6 +3105,164 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
2961
3105
|
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)."),
|
|
2962
3106
|
},
|
|
2963
3107
|
}, addDatabaseRowHandler);
|
|
3108
|
+
const readDatabaseCellsHandler = async (parsed) => {
|
|
3109
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3110
|
+
if (!workspaceId)
|
|
3111
|
+
throw new Error("workspaceId is required");
|
|
3112
|
+
const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
|
|
3113
|
+
try {
|
|
3114
|
+
const requestedRows = parsed.rowBlockIds?.length
|
|
3115
|
+
? parsed.rowBlockIds
|
|
3116
|
+
: getDatabaseRowIds(ctx.dbBlock);
|
|
3117
|
+
const requestedColumns = parsed.columns?.length
|
|
3118
|
+
? parsed.columns.map(columnKey => {
|
|
3119
|
+
const col = findDatabaseColumn(columnKey, ctx);
|
|
3120
|
+
if (!col) {
|
|
3121
|
+
throw new Error(`Column '${columnKey}' not found. Available columns: ${availableDatabaseColumns(ctx)}`);
|
|
3122
|
+
}
|
|
3123
|
+
return col;
|
|
3124
|
+
})
|
|
3125
|
+
: ctx.columnDefs;
|
|
3126
|
+
const requestedColumnIds = new Set(requestedColumns.map(col => col.id));
|
|
3127
|
+
const rows = requestedRows.map(rowBlockId => {
|
|
3128
|
+
const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, rowBlockId);
|
|
3129
|
+
const title = readDatabaseRowTitle(rowBlock) || null;
|
|
3130
|
+
const rowCells = ctx.cellsMap.get(rowBlockId);
|
|
3131
|
+
const cells = {};
|
|
3132
|
+
if (rowCells instanceof Y.Map) {
|
|
3133
|
+
for (const col of ctx.columnDefs) {
|
|
3134
|
+
if (ctx.titleCol && col.id === ctx.titleCol.id) {
|
|
3135
|
+
continue;
|
|
3136
|
+
}
|
|
3137
|
+
if (!requestedColumnIds.has(col.id)) {
|
|
3138
|
+
continue;
|
|
3139
|
+
}
|
|
3140
|
+
const cellEntry = rowCells.get(col.id);
|
|
3141
|
+
if (cellEntry === undefined) {
|
|
3142
|
+
continue;
|
|
3143
|
+
}
|
|
3144
|
+
cells[col.name || col.id] = decodeDatabaseCellValue(col, cellEntry);
|
|
3145
|
+
}
|
|
3146
|
+
}
|
|
3147
|
+
return {
|
|
3148
|
+
rowBlockId,
|
|
3149
|
+
title,
|
|
3150
|
+
cells,
|
|
3151
|
+
};
|
|
3152
|
+
});
|
|
3153
|
+
return text({ rows });
|
|
3154
|
+
}
|
|
3155
|
+
finally {
|
|
3156
|
+
ctx.socket.disconnect();
|
|
3157
|
+
}
|
|
3158
|
+
};
|
|
3159
|
+
server.registerTool("read_database_cells", {
|
|
3160
|
+
title: "Read Database Cells",
|
|
3161
|
+
description: "Read row titles and database cell values from an AFFiNE database block.",
|
|
3162
|
+
inputSchema: {
|
|
3163
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
3164
|
+
docId: DocId.describe("Document ID containing the database"),
|
|
3165
|
+
databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
|
|
3166
|
+
rowBlockIds: z.array(z.string().min(1)).optional().describe("Optional row block ID filter. Omit to return all rows."),
|
|
3167
|
+
columns: z.array(z.string().min(1)).optional().describe("Optional column name or ID filter."),
|
|
3168
|
+
},
|
|
3169
|
+
}, readDatabaseCellsHandler);
|
|
3170
|
+
const updateDatabaseCellHandler = async (parsed) => {
|
|
3171
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3172
|
+
if (!workspaceId)
|
|
3173
|
+
throw new Error("workspaceId is required");
|
|
3174
|
+
const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
|
|
3175
|
+
try {
|
|
3176
|
+
const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, parsed.rowBlockId);
|
|
3177
|
+
const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
|
|
3178
|
+
const col = findDatabaseColumn(parsed.column, ctx);
|
|
3179
|
+
if (!col) {
|
|
3180
|
+
if (!isTitleAliasKey(parsed.column)) {
|
|
3181
|
+
throw new Error(`Column '${parsed.column}' not found. Available columns: ${availableDatabaseColumns(ctx)}`);
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
else {
|
|
3185
|
+
writeDatabaseCellValue(rowCells, col, parsed.value, parsed.createOption ?? true);
|
|
3186
|
+
}
|
|
3187
|
+
if (isTitleAliasKey(parsed.column) || (col && (col.type === "title" || isTitleAliasKey(col.name)))) {
|
|
3188
|
+
rowBlock.set("prop:text", makeText(String(parsed.value ?? "")));
|
|
3189
|
+
}
|
|
3190
|
+
const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
|
|
3191
|
+
await pushDocUpdate(ctx.socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
3192
|
+
return text({
|
|
3193
|
+
updated: true,
|
|
3194
|
+
rowBlockId: parsed.rowBlockId,
|
|
3195
|
+
column: parsed.column,
|
|
3196
|
+
value: parsed.value ?? null,
|
|
3197
|
+
});
|
|
3198
|
+
}
|
|
3199
|
+
finally {
|
|
3200
|
+
ctx.socket.disconnect();
|
|
3201
|
+
}
|
|
3202
|
+
};
|
|
3203
|
+
server.registerTool("update_database_cell", {
|
|
3204
|
+
title: "Update Database Cell",
|
|
3205
|
+
description: "Update a single cell on an existing AFFiNE database row. Use `title` to update the row title shown in Kanban card headers.",
|
|
3206
|
+
inputSchema: {
|
|
3207
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
3208
|
+
docId: DocId.describe("Document ID containing the database"),
|
|
3209
|
+
databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
|
|
3210
|
+
rowBlockId: z.string().min(1).describe("Row paragraph block ID"),
|
|
3211
|
+
column: z.string().min(1).describe("Column name or ID. Use `title` for the built-in row title."),
|
|
3212
|
+
value: z.unknown().describe("New cell value"),
|
|
3213
|
+
createOption: z.boolean().optional().describe("For select and multi-select columns, create the option label if it does not exist (default true)"),
|
|
3214
|
+
},
|
|
3215
|
+
}, updateDatabaseCellHandler);
|
|
3216
|
+
const updateDatabaseRowHandler = async (parsed) => {
|
|
3217
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3218
|
+
if (!workspaceId)
|
|
3219
|
+
throw new Error("workspaceId is required");
|
|
3220
|
+
const ctx = await loadDatabaseDocContext(workspaceId, parsed.docId, parsed.databaseBlockId);
|
|
3221
|
+
try {
|
|
3222
|
+
const rowBlock = getDatabaseRowBlock(ctx.blocks, parsed.databaseBlockId, parsed.rowBlockId);
|
|
3223
|
+
const rowCells = ensureDatabaseRowCells(ctx.cellsMap, parsed.rowBlockId);
|
|
3224
|
+
let titleValue = null;
|
|
3225
|
+
for (const [key, value] of Object.entries(parsed.cells)) {
|
|
3226
|
+
const col = findDatabaseColumn(key, ctx);
|
|
3227
|
+
if (!col) {
|
|
3228
|
+
if (isTitleAliasKey(key)) {
|
|
3229
|
+
titleValue = String(value ?? "");
|
|
3230
|
+
continue;
|
|
3231
|
+
}
|
|
3232
|
+
throw new Error(`Column '${key}' not found. Available columns: ${availableDatabaseColumns(ctx)}`);
|
|
3233
|
+
}
|
|
3234
|
+
writeDatabaseCellValue(rowCells, col, value, parsed.createOption ?? true);
|
|
3235
|
+
if (col.type === "title" || isTitleAliasKey(col.name)) {
|
|
3236
|
+
titleValue = String(value ?? "");
|
|
3237
|
+
}
|
|
3238
|
+
}
|
|
3239
|
+
if (titleValue !== null) {
|
|
3240
|
+
rowBlock.set("prop:text", makeText(titleValue));
|
|
3241
|
+
}
|
|
3242
|
+
const delta = Y.encodeStateAsUpdate(ctx.doc, ctx.prevSV);
|
|
3243
|
+
await pushDocUpdate(ctx.socket, workspaceId, parsed.docId, Buffer.from(delta).toString("base64"));
|
|
3244
|
+
return text({
|
|
3245
|
+
updated: true,
|
|
3246
|
+
rowBlockId: parsed.rowBlockId,
|
|
3247
|
+
cellCount: Object.keys(parsed.cells).length,
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
finally {
|
|
3251
|
+
ctx.socket.disconnect();
|
|
3252
|
+
}
|
|
3253
|
+
};
|
|
3254
|
+
server.registerTool("update_database_row", {
|
|
3255
|
+
title: "Update Database Row",
|
|
3256
|
+
description: "Batch update multiple cells on an existing AFFiNE database row. Include `title` in the cells map to update the Kanban row title.",
|
|
3257
|
+
inputSchema: {
|
|
3258
|
+
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)"),
|
|
3259
|
+
docId: DocId.describe("Document ID containing the database"),
|
|
3260
|
+
databaseBlockId: z.string().min(1).describe("Block ID of the affine:database block"),
|
|
3261
|
+
rowBlockId: z.string().min(1).describe("Row paragraph block ID"),
|
|
3262
|
+
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."),
|
|
3263
|
+
createOption: z.boolean().optional().describe("For select and multi-select columns, create the option label if it does not exist (default true)"),
|
|
3264
|
+
},
|
|
3265
|
+
}, updateDatabaseRowHandler);
|
|
2964
3266
|
// ADD DATABASE COLUMN
|
|
2965
3267
|
const addDatabaseColumnHandler = async (parsed) => {
|
|
2966
3268
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "affine-mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.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.",
|
|
@@ -33,10 +33,12 @@
|
|
|
33
33
|
"start": "node dist/index.js",
|
|
34
34
|
"start:http": "MCP_TRANSPORT=http node dist/index.js",
|
|
35
35
|
"test": "npm run test:tool-manifest",
|
|
36
|
+
"test:cli-version": "node tests/test-cli-version.mjs",
|
|
36
37
|
"test:tool-manifest": "node scripts/verify-tool-manifest.mjs",
|
|
37
38
|
"test:comprehensive": "node test-comprehensive.mjs",
|
|
38
39
|
"test:e2e": "bash tests/run-e2e.sh",
|
|
39
40
|
"test:db-create": "node tests/test-database-creation.mjs",
|
|
41
|
+
"test:db-cells": "node tests/test-database-cells.mjs",
|
|
40
42
|
"test:bearer": "node tests/test-bearer-auth.mjs",
|
|
41
43
|
"test:tag-visibility": "node tests/test-tag-visibility.mjs",
|
|
42
44
|
"test:playwright": "npx playwright test --config tests/playwright/playwright.config.ts",
|