affine-mcp-server 2.3.0 → 2.4.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 +3 -3
- package/dist/toolSurface.js +2 -0
- package/dist/tools/docs.js +168 -8
- package/docs/tool-reference.md +1 -0
- package/package.json +3 -2
- package/tool-manifest.json +2 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A Model Context Protocol (MCP) server for AFFiNE. It exposes AFFiNE workspaces and documents to AI assistants over stdio (default) or HTTP (`/mcp`) and supports both AFFiNE Cloud and self-hosted deployments.
|
|
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)
|
|
@@ -38,7 +38,7 @@ Highlights:
|
|
|
38
38
|
- Supports AFFiNE Cloud and self-hosted AFFiNE instances
|
|
39
39
|
- Supports stdio and HTTP transports
|
|
40
40
|
- Supports token, cookie, and email/password authentication
|
|
41
|
-
- Exposes
|
|
41
|
+
- Exposes 95 canonical MCP tools backed by AFFiNE GraphQL and WebSocket APIs
|
|
42
42
|
- Includes semantic page composition, native template instantiation, database intent composition, capability and fidelity reporting, and workspace blueprint helpers
|
|
43
43
|
- Includes Docker images, health probes, and end-to-end test coverage
|
|
44
44
|
|
|
@@ -48,7 +48,7 @@ Scope boundaries:
|
|
|
48
48
|
- Browser-local workspaces stored only in local storage are not available through AFFiNE server APIs
|
|
49
49
|
- AFFiNE Cloud requires API-token-based access for MCP usage; programmatic email/password sign-in is blocked by Cloudflare
|
|
50
50
|
|
|
51
|
-
> New in v2.
|
|
51
|
+
> New in v2.4.0: Added `delete_tag` for workspace tag cleanup and surfaced `inTrash` across document listing and hierarchy tools.
|
|
52
52
|
|
|
53
53
|
## Choose Your Path
|
|
54
54
|
| Goal | Start here |
|
package/dist/toolSurface.js
CHANGED
|
@@ -33,6 +33,7 @@ const ALL_TOOLS = [
|
|
|
33
33
|
"delete_folder",
|
|
34
34
|
"delete_organize_link",
|
|
35
35
|
"delete_surface_element",
|
|
36
|
+
"delete_tag",
|
|
36
37
|
"delete_workspace",
|
|
37
38
|
"export_doc_markdown",
|
|
38
39
|
"export_with_fidelity_report",
|
|
@@ -129,6 +130,7 @@ const TOOL_GROUPS = {
|
|
|
129
130
|
delete_folder: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
|
|
130
131
|
delete_organize_link: ["organize", "organize.folders", "organize.write", "destructive", "experimental", "write"],
|
|
131
132
|
delete_surface_element: ["docs", "docs.edgeless", "docs.surface", "docs.write", "destructive", "write"],
|
|
133
|
+
delete_tag: ["docs", "docs.tags", "docs.write", "destructive", "write"],
|
|
132
134
|
delete_workspace: ["workspaces", "workspaces.write", "admin", "destructive", "write"],
|
|
133
135
|
export_doc_markdown: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
|
|
134
136
|
export_with_fidelity_report: ["docs", "docs.export", "docs.markdown", "docs.read", "read"],
|
package/dist/tools/docs.js
CHANGED
|
@@ -463,6 +463,31 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
463
463
|
optionsArray.push([optionMap]);
|
|
464
464
|
return { option, created: true };
|
|
465
465
|
}
|
|
466
|
+
/**
|
|
467
|
+
* Resolve the single tag option targeted for deletion. Matches a tag id
|
|
468
|
+
* first (ids are unique), then falls back to a case-insensitive name match.
|
|
469
|
+
* Throws when no tag matches, or when a name is shared by several tags so the
|
|
470
|
+
* caller can re-run with a specific id rather than deleting the wrong one.
|
|
471
|
+
*/
|
|
472
|
+
function findTagOptionForDeletion(meta, tag) {
|
|
473
|
+
const normalized = normalizeTag(tag);
|
|
474
|
+
const { options, byId } = getWorkspaceTagOptionMaps(meta);
|
|
475
|
+
const byIdMatch = byId.get(normalized);
|
|
476
|
+
if (byIdMatch) {
|
|
477
|
+
return { option: byIdMatch, matchedBy: "id" };
|
|
478
|
+
}
|
|
479
|
+
const lower = normalized.toLocaleLowerCase();
|
|
480
|
+
const valueMatches = options.filter((option) => option.value.toLocaleLowerCase() === lower);
|
|
481
|
+
if (valueMatches.length === 0) {
|
|
482
|
+
throw new Error(`Tag "${normalized}" was not found in workspace tag options.`);
|
|
483
|
+
}
|
|
484
|
+
if (valueMatches.length > 1) {
|
|
485
|
+
const candidates = valueMatches.map((option) => `${option.id} ("${option.value}")`).join(", ");
|
|
486
|
+
throw new Error(`Tag name "${normalized}" is ambiguous; ${valueMatches.length} tags share this name. ` +
|
|
487
|
+
`Re-run delete_tag with a specific tag id: ${candidates}`);
|
|
488
|
+
}
|
|
489
|
+
return { option: valueMatches[0], matchedBy: "value" };
|
|
490
|
+
}
|
|
466
491
|
function collectMatchingTagIndexes(tags, requestedTag, option, ignoreCase) {
|
|
467
492
|
const normalizedRequested = ignoreCase ? requestedTag.toLocaleLowerCase() : requestedTag;
|
|
468
493
|
const normalizedOptionId = option
|
|
@@ -551,12 +576,19 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
551
576
|
const title = value.get("title");
|
|
552
577
|
const createDate = value.get("createDate");
|
|
553
578
|
const updatedDate = value.get("updatedDate");
|
|
579
|
+
const inTrashRaw = value.get("inTrash");
|
|
580
|
+
const trashRaw = value.get("trash");
|
|
581
|
+
const trashDate = value.get("trashDate");
|
|
582
|
+
const inTrash = typeof inTrashRaw === "boolean" ? inTrashRaw
|
|
583
|
+
: typeof trashRaw === "boolean" ? trashRaw
|
|
584
|
+
: (typeof trashDate === "number" && trashDate > 0);
|
|
554
585
|
entries.push({
|
|
555
586
|
index,
|
|
556
587
|
id,
|
|
557
588
|
title: typeof title === "string" ? title : null,
|
|
558
589
|
createDate: typeof createDate === "number" ? createDate : null,
|
|
559
590
|
updatedDate: typeof updatedDate === "number" ? updatedDate : null,
|
|
591
|
+
inTrash,
|
|
560
592
|
entry: value,
|
|
561
593
|
tagsArray: getTagArray(value),
|
|
562
594
|
});
|
|
@@ -3177,6 +3209,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3177
3209
|
const docs = data.workspace.docs;
|
|
3178
3210
|
const tagsByDocId = new Map();
|
|
3179
3211
|
const titlesByDocId = new Map();
|
|
3212
|
+
const inTrashByDocId = new Map();
|
|
3180
3213
|
let workspacePageCount = null;
|
|
3181
3214
|
let workspacePageIds = null;
|
|
3182
3215
|
const deletedDocIds = new Set();
|
|
@@ -3201,6 +3234,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3201
3234
|
}
|
|
3202
3235
|
const tagEntries = getStringArray(page.tagsArray);
|
|
3203
3236
|
tagsByDocId.set(page.id, resolveTagLabels(tagEntries, byId));
|
|
3237
|
+
inTrashByDocId.set(page.id, page.inTrash);
|
|
3204
3238
|
}
|
|
3205
3239
|
}
|
|
3206
3240
|
const graphEdges = Array.isArray(docs?.edges) ? docs.edges : [];
|
|
@@ -3239,6 +3273,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3239
3273
|
...node,
|
|
3240
3274
|
title: titlesByDocId.get(node.id) || node.title,
|
|
3241
3275
|
tags: tagsByDocId.get(node.id) || [],
|
|
3276
|
+
inTrash: inTrashByDocId.get(node.id) ?? false,
|
|
3242
3277
|
},
|
|
3243
3278
|
};
|
|
3244
3279
|
})
|
|
@@ -3272,7 +3307,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3272
3307
|
};
|
|
3273
3308
|
server.registerTool("list_docs", {
|
|
3274
3309
|
title: "List Documents",
|
|
3275
|
-
description: "List documents in a workspace (GraphQL).",
|
|
3310
|
+
description: "List documents in a workspace (GraphQL). Each doc includes an inTrash flag.",
|
|
3276
3311
|
inputSchema: {
|
|
3277
3312
|
workspaceId: z.string().describe("Workspace ID (optional if default set).").optional(),
|
|
3278
3313
|
first: z.number().optional(),
|
|
@@ -3401,6 +3436,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3401
3436
|
updatedAt: updatedTimestamp > 0 ? new Date(updatedTimestamp).toISOString() : null,
|
|
3402
3437
|
updatedTimestamp,
|
|
3403
3438
|
url: `${baseUrl}/workspace/${workspaceId}/${page.id}`,
|
|
3439
|
+
inTrash: page.inTrash,
|
|
3404
3440
|
rank,
|
|
3405
3441
|
};
|
|
3406
3442
|
})
|
|
@@ -3429,6 +3465,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3429
3465
|
tags: entry.tags,
|
|
3430
3466
|
updatedAt: entry.updatedAt,
|
|
3431
3467
|
url: entry.url,
|
|
3468
|
+
inTrash: entry.inTrash,
|
|
3432
3469
|
}));
|
|
3433
3470
|
return text({
|
|
3434
3471
|
query: parsed.query,
|
|
@@ -3446,7 +3483,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3446
3483
|
};
|
|
3447
3484
|
server.registerTool("search_docs", {
|
|
3448
3485
|
title: "Search Documents by Title",
|
|
3449
|
-
description: "Fast search for documents by title using workspace metadata. Much faster than exporting each doc. Returns docId, title,
|
|
3486
|
+
description: "Fast search for documents by title using workspace metadata. Much faster than exporting each doc. Returns docId, title, direct URL, and inTrash for each match.",
|
|
3450
3487
|
inputSchema: {
|
|
3451
3488
|
workspaceId: z.string().optional().describe("Workspace ID (optional if default set)."),
|
|
3452
3489
|
query: z.string().describe("Search query — matched case-insensitively against doc titles."),
|
|
@@ -3510,6 +3547,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3510
3547
|
title: pageTitle,
|
|
3511
3548
|
createdAt: page.createDate ? new Date(page.createDate).toISOString() : null,
|
|
3512
3549
|
updatedAt: updatedTimestamp ? new Date(updatedTimestamp).toISOString() : null,
|
|
3550
|
+
inTrash: page.inTrash,
|
|
3513
3551
|
});
|
|
3514
3552
|
}
|
|
3515
3553
|
return text({
|
|
@@ -3531,7 +3569,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3531
3569
|
"Reads workspace metadata — fast, no per-doc fetch. " +
|
|
3532
3570
|
"Unlike `search_docs` (which is always case-insensitive and capped at limit 20), this tool defaults to case-sensitive matching and returns up to `limit` matches (default 50, max 200). " +
|
|
3533
3571
|
"Prefer this over `search_docs` when you know the exact title and want every match. " +
|
|
3534
|
-
"Returns: { query, caseInsensitive, matches: [{ id, title, createdAt, updatedAt }], workspaceDocCount, truncated }.",
|
|
3572
|
+
"Returns: { query, caseInsensitive, matches: [{ id, title, createdAt, updatedAt, inTrash }], workspaceDocCount, truncated }.",
|
|
3535
3573
|
inputSchema: {
|
|
3536
3574
|
workspaceId: z.string().optional().describe("Workspace ID (optional if AFFINE_WORKSPACE_ID is set)."),
|
|
3537
3575
|
title: z.string().min(1).describe("The exact title to match."),
|
|
@@ -3578,6 +3616,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3578
3616
|
updatedDate: page.updatedDate,
|
|
3579
3617
|
tags,
|
|
3580
3618
|
rawTags,
|
|
3619
|
+
inTrash: page.inTrash,
|
|
3581
3620
|
};
|
|
3582
3621
|
})
|
|
3583
3622
|
.filter((page) => hasTag(page.tags, tag, ignoreCase) || hasTag(page.rawTags, tag, ignoreCase))
|
|
@@ -3596,7 +3635,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3596
3635
|
};
|
|
3597
3636
|
server.registerTool("list_docs_by_tag", {
|
|
3598
3637
|
title: "List Documents By Tag",
|
|
3599
|
-
description: "List documents that contain the requested tag.",
|
|
3638
|
+
description: "List documents that contain the requested tag. Each doc includes an inTrash flag.",
|
|
3600
3639
|
inputSchema: {
|
|
3601
3640
|
workspaceId: WorkspaceId.optional(),
|
|
3602
3641
|
tag: z.string().min(1).describe("Tag name"),
|
|
@@ -3790,6 +3829,121 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
3790
3829
|
tag: z.string().min(1).describe("Tag name"),
|
|
3791
3830
|
},
|
|
3792
3831
|
}, removeTagFromDocHandler);
|
|
3832
|
+
/**
|
|
3833
|
+
* Delete a workspace-level tag and detach it from every document that
|
|
3834
|
+
* references it, mirroring AFFiNE's TagStore.removeTagOption. Resolves the
|
|
3835
|
+
* tag by id or name (ambiguous names are rejected), removes the option from
|
|
3836
|
+
* meta.properties.tags.options, strips the tag id from each page's tag array,
|
|
3837
|
+
* then syncs each affected document's own metadata. All workspace-root edits
|
|
3838
|
+
* are applied to an in-memory Y.Doc and pushed as a single delta, so a failed
|
|
3839
|
+
* push leaves the server unchanged and the operation safely retriable.
|
|
3840
|
+
*/
|
|
3841
|
+
const deleteTagHandler = async (parsed) => {
|
|
3842
|
+
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3843
|
+
if (!workspaceId) {
|
|
3844
|
+
throw new Error("workspaceId is required. Provide it as a parameter or set AFFINE_WORKSPACE_ID in environment.");
|
|
3845
|
+
}
|
|
3846
|
+
const tag = normalizeTag(parsed.tag);
|
|
3847
|
+
const { endpoint, cookie, bearer } = await getCookieAndEndpoint();
|
|
3848
|
+
const wsUrl = wsUrlFromGraphQLEndpoint(endpoint);
|
|
3849
|
+
const socket = await connectWorkspaceSocket(wsUrl, cookie, bearer);
|
|
3850
|
+
try {
|
|
3851
|
+
await joinWorkspace(socket, workspaceId);
|
|
3852
|
+
const wsSnapshot = await loadDoc(socket, workspaceId, workspaceId);
|
|
3853
|
+
if (!wsSnapshot.missing) {
|
|
3854
|
+
throw new Error(`Workspace root document not found for workspace ${workspaceId}`);
|
|
3855
|
+
}
|
|
3856
|
+
const wsDoc = new Y.Doc();
|
|
3857
|
+
Y.applyUpdate(wsDoc, Buffer.from(wsSnapshot.missing, "base64"));
|
|
3858
|
+
const wsPrevSV = Y.encodeStateVector(wsDoc);
|
|
3859
|
+
const wsMeta = wsDoc.getMap("meta");
|
|
3860
|
+
const { option } = findTagOptionForDeletion(wsMeta, tag);
|
|
3861
|
+
// Step 1: drop the tag option itself from the workspace tag registry.
|
|
3862
|
+
const optionsArray = getWorkspaceTagOptionsArray(wsMeta);
|
|
3863
|
+
let optionRemoved = false;
|
|
3864
|
+
if (optionsArray) {
|
|
3865
|
+
const optionIndexes = [];
|
|
3866
|
+
optionsArray.forEach((raw, index) => {
|
|
3867
|
+
const parsedOption = parseWorkspaceTagOption(raw);
|
|
3868
|
+
if (parsedOption && parsedOption.id === option.id) {
|
|
3869
|
+
optionIndexes.push(index);
|
|
3870
|
+
}
|
|
3871
|
+
});
|
|
3872
|
+
optionRemoved = deleteArrayIndexes(optionsArray, optionIndexes);
|
|
3873
|
+
}
|
|
3874
|
+
// Step 2: strip the tag id from every page entry that still references it.
|
|
3875
|
+
// Match by id only (case-sensitive), mirroring AFFiNE's removeTagOption
|
|
3876
|
+
// (`t !== id`): doc tags are stored as canonical ids, and matching by
|
|
3877
|
+
// value could clobber a same-name sibling tag's references.
|
|
3878
|
+
const affectedDocIds = [];
|
|
3879
|
+
for (const page of getWorkspacePageEntries(wsMeta)) {
|
|
3880
|
+
const pageTags = page.tagsArray;
|
|
3881
|
+
if (!pageTags) {
|
|
3882
|
+
continue;
|
|
3883
|
+
}
|
|
3884
|
+
const indexes = collectMatchingTagIndexes(pageTags, option.id, null, false);
|
|
3885
|
+
if (deleteArrayIndexes(pageTags, indexes)) {
|
|
3886
|
+
affectedDocIds.push(page.id);
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
if (optionRemoved || affectedDocIds.length > 0) {
|
|
3890
|
+
const wsDelta = Y.encodeStateAsUpdate(wsDoc, wsPrevSV);
|
|
3891
|
+
await pushDocUpdate(socket, workspaceId, workspaceId, Buffer.from(wsDelta).toString("base64"));
|
|
3892
|
+
}
|
|
3893
|
+
// Step 3: mirror the cleanup in each affected document's own metadata.
|
|
3894
|
+
let docMetaSynced = 0;
|
|
3895
|
+
const warnings = [];
|
|
3896
|
+
// The workspace registry is the source of truth and has already been
|
|
3897
|
+
// updated; per-document metadata is a secondary sync. Treat each doc as
|
|
3898
|
+
// best-effort so one failing push degrades to a warning instead of
|
|
3899
|
+
// throwing and leaving a non-retriable partial state.
|
|
3900
|
+
for (const docId of affectedDocIds) {
|
|
3901
|
+
try {
|
|
3902
|
+
const docSnapshot = await loadDoc(socket, workspaceId, docId);
|
|
3903
|
+
if (!docSnapshot.missing) {
|
|
3904
|
+
warnings.push(`Document ${docId} snapshot not found; workspace tag map was updated only.`);
|
|
3905
|
+
continue;
|
|
3906
|
+
}
|
|
3907
|
+
const doc = new Y.Doc();
|
|
3908
|
+
Y.applyUpdate(doc, Buffer.from(docSnapshot.missing, "base64"));
|
|
3909
|
+
const docPrevSV = Y.encodeStateVector(doc);
|
|
3910
|
+
const docTags = getTagArray(doc.getMap("meta"));
|
|
3911
|
+
if (docTags) {
|
|
3912
|
+
const docIndexes = collectMatchingTagIndexes(docTags, option.id, null, false);
|
|
3913
|
+
if (deleteArrayIndexes(docTags, docIndexes)) {
|
|
3914
|
+
const docDelta = Y.encodeStateAsUpdate(doc, docPrevSV);
|
|
3915
|
+
await pushDocUpdate(socket, workspaceId, docId, Buffer.from(docDelta).toString("base64"));
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
docMetaSynced += 1;
|
|
3919
|
+
}
|
|
3920
|
+
catch (err) {
|
|
3921
|
+
warnings.push(`Document ${docId} metadata sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
return text({
|
|
3925
|
+
workspaceId,
|
|
3926
|
+
tag,
|
|
3927
|
+
tagId: option.id,
|
|
3928
|
+
value: option.value,
|
|
3929
|
+
deleted: optionRemoved,
|
|
3930
|
+
affectedDocs: affectedDocIds.length,
|
|
3931
|
+
docMetaSynced,
|
|
3932
|
+
warnings,
|
|
3933
|
+
});
|
|
3934
|
+
}
|
|
3935
|
+
finally {
|
|
3936
|
+
socket.disconnect();
|
|
3937
|
+
}
|
|
3938
|
+
};
|
|
3939
|
+
server.registerTool("delete_tag", {
|
|
3940
|
+
title: "Delete Tag",
|
|
3941
|
+
description: "Delete a workspace-level tag and remove it from every document that references it. Accepts a tag id or name; an ambiguous name is rejected with the candidate ids.",
|
|
3942
|
+
inputSchema: {
|
|
3943
|
+
workspaceId: WorkspaceId.optional(),
|
|
3944
|
+
tag: z.string().min(1).describe("Tag id or name to delete"),
|
|
3945
|
+
},
|
|
3946
|
+
}, deleteTagHandler);
|
|
3793
3947
|
const getDocHandler = async (parsed) => {
|
|
3794
3948
|
const workspaceId = parsed.workspaceId || defaults.workspaceId;
|
|
3795
3949
|
if (!workspaceId) {
|
|
@@ -4820,6 +4974,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4820
4974
|
Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
|
|
4821
4975
|
const pages = getWorkspacePageEntries(wsDoc.getMap("meta"));
|
|
4822
4976
|
const titleById = new Map(pages.map(p => [p.id, p.title ?? "Untitled"]));
|
|
4977
|
+
const trashById = new Map(pages.map(p => [p.id, p.inTrash]));
|
|
4823
4978
|
const childrenOf = new Map();
|
|
4824
4979
|
const allChildren = new Set();
|
|
4825
4980
|
for (const page of pages) {
|
|
@@ -4844,6 +4999,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4844
4999
|
const buildNode = (id, depth) => ({
|
|
4845
5000
|
docId: id, title: titleById.get(id) ?? "Untitled",
|
|
4846
5001
|
url: `${baseUrl}/workspace/${workspaceId}/${id}`,
|
|
5002
|
+
inTrash: trashById.get(id) ?? false,
|
|
4847
5003
|
children: depth < maxDepth ? (childrenOf.get(id) ?? []).map(cid => buildNode(cid, depth + 1)) : [],
|
|
4848
5004
|
});
|
|
4849
5005
|
return text({ workspaceId, totalDocs: pages.length, rootCount: roots.length, tree: roots.map(id => buildNode(id, 0)) });
|
|
@@ -4854,7 +5010,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4854
5010
|
};
|
|
4855
5011
|
server.registerTool("list_workspace_tree", {
|
|
4856
5012
|
title: "List Workspace Tree",
|
|
4857
|
-
description: "Returns the full document hierarchy as a tree (roots → children → grandchildren). Use depth to limit nesting (default: 3). Note: loads all docs — may be slow on large workspaces.",
|
|
5013
|
+
description: "Returns the full document hierarchy as a tree (roots → children → grandchildren). Use depth to limit nesting (default: 3). Note: loads all docs — may be slow on large workspaces. Each node includes an inTrash flag.",
|
|
4858
5014
|
inputSchema: {
|
|
4859
5015
|
workspaceId: z.string().optional(),
|
|
4860
5016
|
depth: z.number().optional().describe("Max nesting depth to return (default: 3)."),
|
|
@@ -4896,6 +5052,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4896
5052
|
docId: p.id,
|
|
4897
5053
|
title: titleById.get(p.id) ?? "Untitled",
|
|
4898
5054
|
url: `${baseUrl}/workspace/${workspaceId}/${p.id}`,
|
|
5055
|
+
inTrash: p.inTrash,
|
|
4899
5056
|
}));
|
|
4900
5057
|
return text({ count: orphans.length, orphans });
|
|
4901
5058
|
}
|
|
@@ -4905,7 +5062,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4905
5062
|
};
|
|
4906
5063
|
server.registerTool("get_orphan_docs", {
|
|
4907
5064
|
title: "Get Orphan Documents",
|
|
4908
|
-
description: "Find all documents that have no parent (not linked from any other doc via embed_linked_doc / embed_synced_doc blocks or inline LinkedPage references). Useful for workspace hygiene. Note: scans all docs — O(n).",
|
|
5065
|
+
description: "Find all documents that have no parent (not linked from any other doc via embed_linked_doc / embed_synced_doc blocks or inline LinkedPage references). Useful for workspace hygiene. Note: scans all docs — O(n). Each doc includes an inTrash flag.",
|
|
4909
5066
|
inputSchema: { workspaceId: z.string().optional() },
|
|
4910
5067
|
}, getOrphanDocsHandler);
|
|
4911
5068
|
const listChildrenHandler = async (parsed) => {
|
|
@@ -4918,6 +5075,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4918
5075
|
try {
|
|
4919
5076
|
await joinWorkspace(socket, workspaceId);
|
|
4920
5077
|
const titleById = new Map();
|
|
5078
|
+
const trashById = new Map();
|
|
4921
5079
|
const workspacePageIds = new Set();
|
|
4922
5080
|
let hasWorkspaceMetadata = false;
|
|
4923
5081
|
const wsSnap = await loadDoc(socket, workspaceId, workspaceId);
|
|
@@ -4927,6 +5085,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4927
5085
|
Y.applyUpdate(wsDoc, Buffer.from(wsSnap.missing, "base64"));
|
|
4928
5086
|
for (const page of getWorkspacePageEntries(wsDoc.getMap("meta"))) {
|
|
4929
5087
|
workspacePageIds.add(page.id);
|
|
5088
|
+
trashById.set(page.id, page.inTrash);
|
|
4930
5089
|
if (page.title)
|
|
4931
5090
|
titleById.set(page.id, page.title);
|
|
4932
5091
|
}
|
|
@@ -4946,7 +5105,8 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4946
5105
|
continue;
|
|
4947
5106
|
seen.add(pageId);
|
|
4948
5107
|
children.push({ docId: pageId, title: titleById.get(pageId) ?? null,
|
|
4949
|
-
url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}
|
|
5108
|
+
url: `${(process.env.AFFINE_BASE_URL || endpoint.replace(/\/graphql\/?$/, '')).replace(/\/$/, '')}/workspace/${workspaceId}/${pageId}`,
|
|
5109
|
+
inTrash: trashById.get(pageId) ?? false });
|
|
4950
5110
|
}
|
|
4951
5111
|
return text({ docId: parsed.docId, count: children.length, children });
|
|
4952
5112
|
}
|
|
@@ -4956,7 +5116,7 @@ export function registerDocTools(server, gql, defaults) {
|
|
|
4956
5116
|
};
|
|
4957
5117
|
server.registerTool("list_children", {
|
|
4958
5118
|
title: "List Document Children",
|
|
4959
|
-
description: "List the direct children of a document in the sidebar (embed_linked_doc / embed_synced_doc blocks and inline LinkedPage references). Returns docId, title, and
|
|
5119
|
+
description: "List the direct children of a document in the sidebar (embed_linked_doc / embed_synced_doc blocks and inline LinkedPage references). Returns docId, title, URL, and inTrash for each child.",
|
|
4960
5120
|
inputSchema: {
|
|
4961
5121
|
workspaceId: z.string().optional(),
|
|
4962
5122
|
docId: z.string().describe("The parent doc whose children to list."),
|
package/docs/tool-reference.md
CHANGED
|
@@ -100,6 +100,7 @@ Use this document as a grouped catalog. For exact schemas, your MCP client shoul
|
|
|
100
100
|
| `create_tag` | Create a reusable workspace-level tag | |
|
|
101
101
|
| `add_tag_to_doc` | Attach a tag to a document | |
|
|
102
102
|
| `remove_tag_from_doc` | Detach a tag from a document | |
|
|
103
|
+
| `delete_tag` | Delete a workspace tag and detach it from every document | Destructive; accepts a tag id or name, rejects an ambiguous name |
|
|
103
104
|
|
|
104
105
|
### Custom properties
|
|
105
106
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "affine-mcp-server",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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.",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"test:create-doc-folder-placement": "node tests/test-create-doc-folder-placement.mjs",
|
|
69
69
|
"test:supporting-tools": "node tests/test-supporting-tools.mjs",
|
|
70
70
|
"test:tag-visibility": "node tests/test-tag-visibility.mjs",
|
|
71
|
+
"test:tag-deletion": "node tests/test-tag-deletion.mjs",
|
|
71
72
|
"test:markdown-roundtrip": "node tests/test-markdown-roundtrip.mjs",
|
|
72
73
|
"test:markdown-rich-text-import": "node tests/test-markdown-rich-text-import.mjs",
|
|
73
74
|
"test:playwright": "npx playwright test --config tests/playwright/playwright.config.ts",
|
|
@@ -99,7 +100,7 @@
|
|
|
99
100
|
"markdown-it": "^14.1.0",
|
|
100
101
|
"node-fetch": "^3.3.2",
|
|
101
102
|
"socket.io-client": "^4.8.1",
|
|
102
|
-
"undici": "^
|
|
103
|
+
"undici": "^6.27.0",
|
|
103
104
|
"yjs": "^13.6.27",
|
|
104
105
|
"zod": "^3.23.8"
|
|
105
106
|
},
|
package/tool-manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.
|
|
2
|
+
"version": "2.4.0",
|
|
3
3
|
"tools": [
|
|
4
4
|
"add_database_column",
|
|
5
5
|
"add_database_row",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"delete_folder",
|
|
36
36
|
"delete_organize_link",
|
|
37
37
|
"delete_surface_element",
|
|
38
|
+
"delete_tag",
|
|
38
39
|
"delete_workspace",
|
|
39
40
|
"export_doc_markdown",
|
|
40
41
|
"export_with_fidelity_report",
|