affine-mcp-server 1.2.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.
@@ -0,0 +1,373 @@
1
+ import { z } from "zod";
2
+ import * as Y from "yjs";
3
+ import FormData from "form-data";
4
+ import fetch from "node-fetch";
5
+ import { io } from "socket.io-client";
6
+ import { text } from "../util/mcp.js";
7
+ // Generate AFFiNE-style document ID
8
+ function generateDocId() {
9
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
10
+ let id = '';
11
+ for (let i = 0; i < 10; i++) {
12
+ id += chars.charAt(Math.floor(Math.random() * chars.length));
13
+ }
14
+ return id;
15
+ }
16
+ // Create initial workspace data with a document
17
+ function createInitialWorkspaceData(workspaceName = 'New Workspace') {
18
+ // Create workspace root YDoc
19
+ const rootDoc = new Y.Doc();
20
+ // Set workspace metadata
21
+ const meta = rootDoc.getMap('meta');
22
+ meta.set('name', workspaceName);
23
+ meta.set('avatar', '');
24
+ // Create pages array with initial document
25
+ const pages = new Y.Array();
26
+ const firstDocId = generateDocId();
27
+ // Add first document metadata
28
+ const pageMetadata = new Y.Map();
29
+ pageMetadata.set('id', firstDocId);
30
+ pageMetadata.set('title', 'Welcome to ' + workspaceName);
31
+ pageMetadata.set('createDate', Date.now());
32
+ pageMetadata.set('tags', new Y.Array());
33
+ pages.push([pageMetadata]);
34
+ meta.set('pages', pages);
35
+ // Create settings
36
+ const setting = rootDoc.getMap('setting');
37
+ setting.set('collections', new Y.Array());
38
+ // Encode workspace update
39
+ const workspaceUpdate = Y.encodeStateAsUpdate(rootDoc);
40
+ // Create the actual document
41
+ const docYDoc = new Y.Doc();
42
+ const blocks = docYDoc.getMap('blocks');
43
+ // Create page block with proper structure
44
+ const pageId = generateDocId();
45
+ const pageBlock = new Y.Map();
46
+ pageBlock.set('sys:id', pageId);
47
+ pageBlock.set('sys:flavour', 'affine:page');
48
+ // Title as Y.Text
49
+ const titleText = new Y.Text();
50
+ titleText.insert(0, 'Welcome to ' + workspaceName);
51
+ pageBlock.set('prop:title', titleText);
52
+ // Children
53
+ const pageChildren = new Y.Array();
54
+ pageBlock.set('sys:children', pageChildren);
55
+ blocks.set(pageId, pageBlock);
56
+ // Add surface block (required)
57
+ const surfaceId = generateDocId();
58
+ const surfaceBlock = new Y.Map();
59
+ surfaceBlock.set('sys:id', surfaceId);
60
+ surfaceBlock.set('sys:flavour', 'affine:surface');
61
+ surfaceBlock.set('sys:parent', pageId);
62
+ surfaceBlock.set('sys:children', new Y.Array());
63
+ blocks.set(surfaceId, surfaceBlock);
64
+ pageChildren.push([surfaceId]);
65
+ // Add note block with xywh
66
+ const noteId = generateDocId();
67
+ const noteBlock = new Y.Map();
68
+ noteBlock.set('sys:id', noteId);
69
+ noteBlock.set('sys:flavour', 'affine:note');
70
+ noteBlock.set('sys:parent', pageId);
71
+ noteBlock.set('prop:displayMode', 'DocAndEdgeless');
72
+ noteBlock.set('prop:xywh', '[0,0,800,600]');
73
+ noteBlock.set('prop:index', 'a0');
74
+ noteBlock.set('prop:lockedBySelf', false);
75
+ const noteChildren = new Y.Array();
76
+ noteBlock.set('sys:children', noteChildren);
77
+ blocks.set(noteId, noteBlock);
78
+ pageChildren.push([noteId]);
79
+ // Add initial paragraph
80
+ const paragraphId = generateDocId();
81
+ const paragraphBlock = new Y.Map();
82
+ paragraphBlock.set('sys:id', paragraphId);
83
+ paragraphBlock.set('sys:flavour', 'affine:paragraph');
84
+ paragraphBlock.set('sys:parent', noteId);
85
+ paragraphBlock.set('sys:children', new Y.Array());
86
+ paragraphBlock.set('prop:type', 'text');
87
+ const paragraphText = new Y.Text();
88
+ paragraphText.insert(0, 'This workspace was created by AFFiNE MCP Server');
89
+ paragraphBlock.set('prop:text', paragraphText);
90
+ blocks.set(paragraphId, paragraphBlock);
91
+ noteChildren.push([paragraphId]);
92
+ // Set document metadata
93
+ const docMeta = docYDoc.getMap('meta');
94
+ docMeta.set('id', firstDocId);
95
+ docMeta.set('title', 'Welcome to ' + workspaceName);
96
+ docMeta.set('createDate', Date.now());
97
+ docMeta.set('tags', new Y.Array());
98
+ docMeta.set('version', 1);
99
+ // Encode document update
100
+ const docUpdate = Y.encodeStateAsUpdate(docYDoc);
101
+ return {
102
+ workspaceUpdate,
103
+ firstDocId,
104
+ docUpdate
105
+ };
106
+ }
107
+ export function registerWorkspaceTools(server, gql) {
108
+ // LIST WORKSPACES
109
+ const listWorkspacesHandler = async () => {
110
+ try {
111
+ const query = `query { workspaces { id public enableAi createdAt } }`;
112
+ const data = await gql.request(query);
113
+ return text(data.workspaces || []);
114
+ }
115
+ catch (error) {
116
+ return text({ error: error.message });
117
+ }
118
+ };
119
+ server.registerTool("list_workspaces", {
120
+ title: "List Workspaces",
121
+ description: "List all available AFFiNE workspaces"
122
+ }, listWorkspacesHandler);
123
+ server.registerTool("affine_list_workspaces", {
124
+ title: "List Workspaces",
125
+ description: "List all available AFFiNE workspaces"
126
+ }, listWorkspacesHandler);
127
+ // GET WORKSPACE
128
+ const getWorkspaceHandler = async ({ id }) => {
129
+ try {
130
+ const query = `query GetWorkspace($id: String!) {
131
+ workspace(id: $id) {
132
+ id
133
+ public
134
+ enableAi
135
+ createdAt
136
+ permissions {
137
+ Workspace_Read
138
+ Workspace_CreateDoc
139
+ }
140
+ }
141
+ }`;
142
+ const data = await gql.request(query, { id });
143
+ return text(data.workspace);
144
+ }
145
+ catch (error) {
146
+ return text({ error: error.message });
147
+ }
148
+ };
149
+ server.registerTool("get_workspace", {
150
+ title: "Get Workspace",
151
+ description: "Get details of a specific workspace",
152
+ inputSchema: {
153
+ id: z.string().describe("Workspace ID")
154
+ }
155
+ }, getWorkspaceHandler);
156
+ server.registerTool("affine_get_workspace", {
157
+ title: "Get Workspace",
158
+ description: "Get details of a specific workspace",
159
+ inputSchema: {
160
+ id: z.string().describe("Workspace ID")
161
+ }
162
+ }, getWorkspaceHandler);
163
+ // CREATE WORKSPACE
164
+ const createWorkspaceHandler = async ({ name, avatar }) => {
165
+ try {
166
+ // Get endpoint and headers from GraphQL client
167
+ const endpoint = gql.endpoint || process.env.AFFINE_BASE_URL + '/graphql';
168
+ const headers = gql.headers || {};
169
+ const cookie = gql.cookie || headers.Cookie || '';
170
+ // Create initial workspace data
171
+ const { workspaceUpdate, firstDocId, docUpdate } = createInitialWorkspaceData(name);
172
+ // Only send workspace update - document will be created separately
173
+ const initData = Buffer.from(workspaceUpdate);
174
+ // Create multipart form
175
+ const form = new FormData();
176
+ // Add GraphQL operation
177
+ form.append('operations', JSON.stringify({
178
+ name: 'createWorkspace',
179
+ query: `mutation createWorkspace($init: Upload!) {
180
+ createWorkspace(init: $init) {
181
+ id
182
+ public
183
+ createdAt
184
+ enableAi
185
+ }
186
+ }`,
187
+ variables: { init: null }
188
+ }));
189
+ // Map file to variable
190
+ form.append('map', JSON.stringify({ '0': ['variables.init'] }));
191
+ // Add workspace init data
192
+ form.append('0', initData, {
193
+ filename: 'init.yjs',
194
+ contentType: 'application/octet-stream'
195
+ });
196
+ // Send request
197
+ const response = await fetch(endpoint, {
198
+ method: 'POST',
199
+ headers: {
200
+ ...headers,
201
+ 'Cookie': cookie,
202
+ ...form.getHeaders()
203
+ },
204
+ body: form
205
+ });
206
+ const result = await response.json();
207
+ if (result.errors) {
208
+ throw new Error(result.errors[0].message);
209
+ }
210
+ const workspace = result.data.createWorkspace;
211
+ // Now create the actual document via WebSocket
212
+ const wsUrl = endpoint.replace('https://', 'wss://').replace('http://', 'ws://').replace('/graphql', '');
213
+ return new Promise((resolve) => {
214
+ const socket = io(wsUrl, {
215
+ transports: ['websocket'],
216
+ path: '/socket.io/',
217
+ extraHeaders: cookie ? { Cookie: cookie } : undefined
218
+ });
219
+ socket.on('connect', () => {
220
+ // Join the workspace
221
+ socket.emit('space:join', {
222
+ spaceType: 'workspace',
223
+ spaceId: workspace.id
224
+ });
225
+ // Send the document update
226
+ setTimeout(() => {
227
+ const docUpdateBase64 = Buffer.from(docUpdate).toString('base64');
228
+ socket.emit('space:push-doc-update', {
229
+ spaceType: 'workspace',
230
+ spaceId: workspace.id,
231
+ docId: firstDocId,
232
+ update: docUpdateBase64
233
+ });
234
+ // Wait longer for sync and disconnect
235
+ setTimeout(() => {
236
+ socket.disconnect();
237
+ resolve(text({
238
+ ...workspace,
239
+ name: name,
240
+ avatar: avatar,
241
+ firstDocId: firstDocId,
242
+ status: "success",
243
+ message: "Workspace created successfully",
244
+ url: `${process.env.AFFINE_BASE_URL}/workspace/${workspace.id}`
245
+ }));
246
+ }, 3000);
247
+ }, 1000);
248
+ });
249
+ socket.on('error', () => {
250
+ socket.disconnect();
251
+ // Even if WebSocket fails, workspace was created
252
+ resolve(text({
253
+ ...workspace,
254
+ name: name,
255
+ avatar: avatar,
256
+ firstDocId: firstDocId,
257
+ status: "partial",
258
+ message: "Workspace created (document sync may be pending)",
259
+ url: `${process.env.AFFINE_BASE_URL}/workspace/${workspace.id}`
260
+ }));
261
+ });
262
+ // Timeout
263
+ setTimeout(() => {
264
+ socket.disconnect();
265
+ resolve(text({
266
+ ...workspace,
267
+ name: name,
268
+ avatar: avatar,
269
+ firstDocId: firstDocId,
270
+ status: "success",
271
+ message: "Workspace created",
272
+ url: `${process.env.AFFINE_BASE_URL}/workspace/${workspace.id}`
273
+ }));
274
+ }, 10000);
275
+ });
276
+ }
277
+ catch (error) {
278
+ return text({ error: error.message, status: "failed" });
279
+ }
280
+ };
281
+ server.registerTool("create_workspace", {
282
+ title: "Create Workspace",
283
+ description: "Create a new workspace with initial document (accessible in UI)",
284
+ inputSchema: {
285
+ name: z.string().describe("Workspace name"),
286
+ avatar: z.string().optional().describe("Avatar emoji or URL")
287
+ }
288
+ }, createWorkspaceHandler);
289
+ server.registerTool("affine_create_workspace", {
290
+ title: "Create Workspace",
291
+ description: "Create a new workspace with initial document (accessible in UI)",
292
+ inputSchema: {
293
+ name: z.string().describe("Workspace name"),
294
+ avatar: z.string().optional().describe("Avatar emoji or URL")
295
+ }
296
+ }, createWorkspaceHandler);
297
+ server.registerTool("affine_create_workspace_fixed", {
298
+ title: "Create Workspace (Fixed)",
299
+ description: "Create a new workspace with initial document (backward compatible alias)",
300
+ inputSchema: {
301
+ name: z.string().describe("Workspace name"),
302
+ avatar: z.string().optional().describe("Avatar emoji or URL")
303
+ }
304
+ }, createWorkspaceHandler);
305
+ // UPDATE WORKSPACE
306
+ const updateWorkspaceHandler = async ({ id, public: isPublic, enableAi }) => {
307
+ try {
308
+ const mutation = `
309
+ mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
310
+ updateWorkspace(input: $input) {
311
+ id
312
+ public
313
+ }
314
+ }
315
+ `;
316
+ const input = { id };
317
+ if (isPublic !== undefined)
318
+ input.public = isPublic;
319
+ const data = await gql.request(mutation, { input });
320
+ return text(data.updateWorkspace);
321
+ }
322
+ catch (error) {
323
+ return text({ error: error.message });
324
+ }
325
+ };
326
+ server.registerTool("update_workspace", {
327
+ title: "Update Workspace",
328
+ description: "Update workspace settings",
329
+ inputSchema: {
330
+ id: z.string().describe("Workspace ID"),
331
+ public: z.boolean().optional().describe("Make workspace public"),
332
+ enableAi: z.boolean().optional().describe("Enable AI features")
333
+ }
334
+ }, updateWorkspaceHandler);
335
+ server.registerTool("affine_update_workspace", {
336
+ title: "Update Workspace",
337
+ description: "Update workspace settings",
338
+ inputSchema: {
339
+ id: z.string().describe("Workspace ID"),
340
+ public: z.boolean().optional().describe("Make workspace public"),
341
+ enableAi: z.boolean().optional().describe("Enable AI features")
342
+ }
343
+ }, updateWorkspaceHandler);
344
+ // DELETE WORKSPACE
345
+ const deleteWorkspaceHandler = async ({ id }) => {
346
+ try {
347
+ const mutation = `
348
+ mutation DeleteWorkspace($id: String!) {
349
+ deleteWorkspace(id: $id)
350
+ }
351
+ `;
352
+ const data = await gql.request(mutation, { id });
353
+ return text({ success: data.deleteWorkspace, message: "Workspace deleted successfully" });
354
+ }
355
+ catch (error) {
356
+ return text({ error: error.message });
357
+ }
358
+ };
359
+ server.registerTool("delete_workspace", {
360
+ title: "Delete Workspace",
361
+ description: "Delete a workspace permanently",
362
+ inputSchema: {
363
+ id: z.string().describe("Workspace ID")
364
+ }
365
+ }, deleteWorkspaceHandler);
366
+ server.registerTool("affine_delete_workspace", {
367
+ title: "Delete Workspace",
368
+ description: "Delete a workspace permanently",
369
+ inputSchema: {
370
+ id: z.string().describe("Workspace ID")
371
+ }
372
+ }, deleteWorkspaceHandler);
373
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export function text(data) {
2
+ const text = typeof data === 'string' ? data : JSON.stringify(data);
3
+ return { content: [{ type: 'text', text }] };
4
+ }
package/dist/ws.js ADDED
@@ -0,0 +1,64 @@
1
+ import { io } from "socket.io-client";
2
+ export function wsUrlFromGraphQLEndpoint(endpoint) {
3
+ return endpoint
4
+ .replace('https://', 'wss://')
5
+ .replace('http://', 'ws://')
6
+ .replace(/\/graphql\/?$/, '');
7
+ }
8
+ export async function connectWorkspaceSocket(wsUrl, cookie) {
9
+ return new Promise((resolve, reject) => {
10
+ const socket = io(wsUrl, {
11
+ transports: ['websocket'],
12
+ path: '/socket.io/',
13
+ extraHeaders: cookie ? { Cookie: cookie } : undefined,
14
+ autoConnect: true
15
+ });
16
+ const onError = (err) => {
17
+ cleanup();
18
+ reject(err);
19
+ };
20
+ const onConnect = () => {
21
+ socket.off('connect_error', onError);
22
+ resolve(socket);
23
+ };
24
+ const cleanup = () => {
25
+ socket.off('connect', onConnect);
26
+ socket.off('connect_error', onError);
27
+ };
28
+ socket.on('connect', onConnect);
29
+ socket.on('connect_error', onError);
30
+ });
31
+ }
32
+ export async function joinWorkspace(socket, workspaceId) {
33
+ return new Promise((resolve, reject) => {
34
+ socket.emit('space:join', { spaceType: 'workspace', spaceId: workspaceId, clientVersion: 'mcp' }, (ack) => {
35
+ if (ack?.error)
36
+ return reject(new Error(ack.error.message || 'join failed'));
37
+ resolve();
38
+ });
39
+ });
40
+ }
41
+ export async function loadDoc(socket, workspaceId, docId) {
42
+ return new Promise((resolve, reject) => {
43
+ socket.emit('space:load-doc', { spaceType: 'workspace', spaceId: workspaceId, docId }, (ack) => {
44
+ if (ack?.error) {
45
+ if (ack.error.name === 'DOC_NOT_FOUND')
46
+ return resolve({});
47
+ return reject(new Error(ack.error.message || 'load-doc failed'));
48
+ }
49
+ resolve(ack?.data || {});
50
+ });
51
+ });
52
+ }
53
+ export async function pushDocUpdate(socket, workspaceId, docId, updateBase64) {
54
+ return new Promise((resolve, reject) => {
55
+ socket.emit('space:push-doc-update', { spaceType: 'workspace', spaceId: workspaceId, docId, update: updateBase64 }, (ack) => {
56
+ if (ack?.error)
57
+ return reject(new Error(ack.error.message || 'push-doc-update failed'));
58
+ resolve(ack?.data?.timestamp || Date.now());
59
+ });
60
+ });
61
+ }
62
+ export function deleteDoc(socket, workspaceId, docId) {
63
+ socket.emit('space:delete-doc', { spaceType: 'workspace', spaceId: workspaceId, docId });
64
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "affine-mcp-server",
3
+ "version": "1.2.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Model Context Protocol server for AFFiNE - enables AI assistants to interact with AFFiNE workspaces, documents, and collaboration features.",
7
+ "author": "dawncr0w",
8
+ "license": "MIT",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/dawncr0w/affine-mcp-server.git"
12
+ },
13
+ "keywords": [
14
+ "mcp",
15
+ "affine",
16
+ "model-context-protocol",
17
+ "ai",
18
+ "claude",
19
+ "knowledge-base"
20
+ ],
21
+ "main": "dist/index.js",
22
+ "bin": {
23
+ "affine-mcp": "dist/index.js"
24
+ },
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "dev": "tsx watch src/index.ts",
28
+ "start": "node dist/index.js",
29
+ "prepublishOnly": "npm run build"
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "engines": {
37
+ "node": ">=18"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@modelcontextprotocol/sdk": "^1.17.2",
44
+ "dotenv": "^16.6.1",
45
+ "form-data": "^4.0.4",
46
+ "node-fetch": "^3.3.2",
47
+ "socket.io-client": "^4.8.1",
48
+ "undici": "^6.19.8",
49
+ "yjs": "^13.6.27",
50
+ "zod": "^3.23.8"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^20.14.11",
54
+ "tsx": "^4.16.2",
55
+ "typescript": "^5.5.4"
56
+ }
57
+ }