workspaces.ts•14.4 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { GraphQLClient } from "../graphqlClient.js";
import * as Y from "yjs";
import FormData from "form-data";
import fetch from "node-fetch";
import { io } from "socket.io-client";
import { text } from "../util/mcp.js";
// Generate AFFiNE-style document ID
function generateDocId(): string {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
  let id = '';
  for (let i = 0; i < 10; i++) {
    id += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  return id;
}
// Create initial workspace data with a document
function createInitialWorkspaceData(workspaceName: string = 'New Workspace') {
  // Create workspace root YDoc
  const rootDoc = new Y.Doc();
  
  // Set workspace metadata
  const meta = rootDoc.getMap('meta');
  meta.set('name', workspaceName);
  meta.set('avatar', '');
  
  // Create pages array with initial document
  const pages = new Y.Array();
  const firstDocId = generateDocId();
  
  // Add first document metadata
  const pageMetadata = new Y.Map();
  pageMetadata.set('id', firstDocId);
  pageMetadata.set('title', 'Welcome to ' + workspaceName);
  pageMetadata.set('createDate', Date.now());
  pageMetadata.set('tags', new Y.Array());
  
  pages.push([pageMetadata]);
  meta.set('pages', pages);
  
  // Create settings
  const setting = rootDoc.getMap('setting');
  setting.set('collections', new Y.Array());
  
  // Encode workspace update
  const workspaceUpdate = Y.encodeStateAsUpdate(rootDoc);
  
  // Create the actual document
  const docYDoc = new Y.Doc();
  const blocks = docYDoc.getMap('blocks');
  
  // Create page block with proper structure
  const pageId = generateDocId();
  const pageBlock = new Y.Map();
  pageBlock.set('sys:id', pageId);
  pageBlock.set('sys:flavour', 'affine:page');
  
  // Title as Y.Text
  const titleText = new Y.Text();
  titleText.insert(0, 'Welcome to ' + workspaceName);
  pageBlock.set('prop:title', titleText);
  
  // Children
  const pageChildren = new Y.Array();
  pageBlock.set('sys:children', pageChildren);
  
  blocks.set(pageId, pageBlock);
  
  // Add surface block (required)
  const surfaceId = generateDocId();
  const surfaceBlock = new Y.Map();
  surfaceBlock.set('sys:id', surfaceId);
  surfaceBlock.set('sys:flavour', 'affine:surface');
  surfaceBlock.set('sys:parent', pageId);
  surfaceBlock.set('sys:children', new Y.Array());
  
  blocks.set(surfaceId, surfaceBlock);
  pageChildren.push([surfaceId]);
  
  // Add note block with xywh
  const noteId = generateDocId();
  const noteBlock = new Y.Map();
  noteBlock.set('sys:id', noteId);
  noteBlock.set('sys:flavour', 'affine:note');
  noteBlock.set('sys:parent', pageId);
  noteBlock.set('prop:displayMode', 'DocAndEdgeless');
  noteBlock.set('prop:xywh', '[0,0,800,600]');
  noteBlock.set('prop:index', 'a0');
  noteBlock.set('prop:lockedBySelf', false);
  
  const noteChildren = new Y.Array();
  noteBlock.set('sys:children', noteChildren);
  
  blocks.set(noteId, noteBlock);
  pageChildren.push([noteId]);
  
  // Add initial paragraph
  const paragraphId = generateDocId();
  const paragraphBlock = new Y.Map();
  paragraphBlock.set('sys:id', paragraphId);
  paragraphBlock.set('sys:flavour', 'affine:paragraph');
  paragraphBlock.set('sys:parent', noteId);
  paragraphBlock.set('sys:children', new Y.Array());
  paragraphBlock.set('prop:type', 'text');
  
  const paragraphText = new Y.Text();
  paragraphText.insert(0, 'This workspace was created by AFFiNE MCP Server');
  paragraphBlock.set('prop:text', paragraphText);
  
  blocks.set(paragraphId, paragraphBlock);
  noteChildren.push([paragraphId]);
  
  // Set document metadata
  const docMeta = docYDoc.getMap('meta');
  docMeta.set('id', firstDocId);
  docMeta.set('title', 'Welcome to ' + workspaceName);
  docMeta.set('createDate', Date.now());
  docMeta.set('tags', new Y.Array());
  docMeta.set('version', 1);
  
  // Encode document update
  const docUpdate = Y.encodeStateAsUpdate(docYDoc);
  
  return {
    workspaceUpdate,
    firstDocId,
    docUpdate
  };
}
export function registerWorkspaceTools(server: McpServer, gql: GraphQLClient) {
  // LIST WORKSPACES
  const listWorkspacesHandler = async () => {
    try {
      const query = `query { workspaces { id public enableAi createdAt } }`;
      const data = await gql.request<{ workspaces: any[] }>(query);
      return text(data.workspaces || []);
    } catch (error: any) {
      return text({ error: error.message });
    }
  };
  server.registerTool(
    "list_workspaces",
    {
      title: "List Workspaces",
      description: "List all available AFFiNE workspaces"
    },
    listWorkspacesHandler as any
  );
  server.registerTool(
    "affine_list_workspaces",
    {
      title: "List Workspaces",
      description: "List all available AFFiNE workspaces"
    },
    listWorkspacesHandler
  );
  // GET WORKSPACE
  const getWorkspaceHandler = async ({ id }: { id: string }) => {
    try {
      const query = `query GetWorkspace($id: String!) { 
        workspace(id: $id) { 
          id 
          public 
          enableAi 
          createdAt
          permissions { 
            Workspace_Read 
            Workspace_CreateDoc 
          } 
        } 
      }`;
      const data = await gql.request<{ workspace: any }>(query, { id });
      return text(data.workspace);
    } catch (error: any) {
      return text({ error: error.message });
    }
  };
  server.registerTool(
    "get_workspace",
    {
      title: "Get Workspace",
      description: "Get details of a specific workspace",
      inputSchema: { 
        id: z.string().describe("Workspace ID") 
      }
    },
    getWorkspaceHandler as any
  );
  server.registerTool(
    "affine_get_workspace",
    {
      title: "Get Workspace",
      description: "Get details of a specific workspace",
      inputSchema: { 
        id: z.string().describe("Workspace ID") 
      }
    },
    getWorkspaceHandler as any
  );
  // CREATE WORKSPACE
  const createWorkspaceHandler = async ({ name, avatar }: { name: string; avatar?: string }) => {
      try {
        // Get endpoint and headers from GraphQL client
        const endpoint = (gql as any).endpoint || process.env.AFFINE_BASE_URL + '/graphql';
        const headers = (gql as any).headers || {};
        const cookie = (gql as any).cookie || headers.Cookie || '';
        
        // Create initial workspace data
        const { workspaceUpdate, firstDocId, docUpdate } = createInitialWorkspaceData(name);
        
        // Only send workspace update - document will be created separately
        const initData = Buffer.from(workspaceUpdate);
        
        // Create multipart form
        const form = new FormData();
        
        // Add GraphQL operation
        form.append('operations', JSON.stringify({
          name: 'createWorkspace',
          query: `mutation createWorkspace($init: Upload!) {
            createWorkspace(init: $init) {
              id
              public
              createdAt
              enableAi
            }
          }`,
          variables: { init: null }
        }));
        
        // Map file to variable
        form.append('map', JSON.stringify({ '0': ['variables.init'] }));
        
        // Add workspace init data
        form.append('0', initData, {
          filename: 'init.yjs',
          contentType: 'application/octet-stream'
        });
        
        // Send request
        const response = await fetch(endpoint, {
          method: 'POST',
          headers: {
            ...headers,
            'Cookie': cookie,
            ...form.getHeaders()
          },
          body: form as any
        });
        
        const result = await response.json() as any;
        
        if (result.errors) {
          throw new Error(result.errors[0].message);
        }
        
        const workspace = result.data.createWorkspace;
        
        // Now create the actual document via WebSocket
        const wsUrl = endpoint.replace('https://', 'wss://').replace('http://', 'ws://').replace('/graphql', '');
        
        return new Promise((resolve) => {
          const socket = io(wsUrl, {
            transports: ['websocket'],
            path: '/socket.io/',
            extraHeaders: cookie ? { Cookie: cookie } : undefined
          });
          
          socket.on('connect', () => {
            // Join the workspace
            socket.emit('space:join', {
              spaceType: 'workspace',
              spaceId: workspace.id
            });
            
            // Send the document update
            setTimeout(() => {
              const docUpdateBase64 = Buffer.from(docUpdate).toString('base64');
              socket.emit('space:push-doc-update', {
                spaceType: 'workspace',
                spaceId: workspace.id,
                docId: firstDocId,
                update: docUpdateBase64
              });
              
              // Wait longer for sync and disconnect
              setTimeout(() => {
                socket.disconnect();
                resolve(text({
                  ...workspace,
                  name: name,
                  avatar: avatar,
                  firstDocId: firstDocId,
                  status: "success",
                  message: "Workspace created successfully",
                  url: `${process.env.AFFINE_BASE_URL}/workspace/${workspace.id}`
                }));
              }, 3000);
            }, 1000);
          });
          
          socket.on('error', () => {
            socket.disconnect();
            // Even if WebSocket fails, workspace was created
            resolve(text({
              ...workspace,
              name: name,
              avatar: avatar,
              firstDocId: firstDocId,
              status: "partial",
              message: "Workspace created (document sync may be pending)",
              url: `${process.env.AFFINE_BASE_URL}/workspace/${workspace.id}`
            }));
          });
          
          // Timeout
          setTimeout(() => {
            socket.disconnect();
            resolve(text({
              ...workspace,
              name: name,
              avatar: avatar,
              firstDocId: firstDocId,
              status: "success",
              message: "Workspace created",
              url: `${process.env.AFFINE_BASE_URL}/workspace/${workspace.id}`
            }));
          }, 10000);
        });
        
      } catch (error: any) {
        return text({ error: error.message, status: "failed" });
      }
    };
  server.registerTool(
    "create_workspace",
    {
      title: "Create Workspace",
      description: "Create a new workspace with initial document (accessible in UI)",
      inputSchema: {
        name: z.string().describe("Workspace name"),
        avatar: z.string().optional().describe("Avatar emoji or URL")
      }
    },
    createWorkspaceHandler as any
  );
  server.registerTool(
    "affine_create_workspace",
    {
      title: "Create Workspace",
      description: "Create a new workspace with initial document (accessible in UI)",
      inputSchema: {
        name: z.string().describe("Workspace name"),
        avatar: z.string().optional().describe("Avatar emoji or URL")
      }
    },
    createWorkspaceHandler as any
  );
  server.registerTool(
    "affine_create_workspace_fixed",
    {
      title: "Create Workspace (Fixed)",
      description: "Create a new workspace with initial document (backward compatible alias)",
      inputSchema: {
        name: z.string().describe("Workspace name"),
        avatar: z.string().optional().describe("Avatar emoji or URL")
      }
    },
    createWorkspaceHandler as any
  );
  // UPDATE WORKSPACE
  const updateWorkspaceHandler = async ({ id, public: isPublic, enableAi }: { id: string; public?: boolean; enableAi?: boolean }) => {
      try {
        const mutation = `
          mutation UpdateWorkspace($input: UpdateWorkspaceInput!) {
            updateWorkspace(input: $input) {
              id
              public
            }
          }
        `;
        
        const input: any = { id };
        if (isPublic !== undefined) input.public = isPublic;
        
        const data = await gql.request<{ updateWorkspace: any }>(mutation, { input });
        
        return text(data.updateWorkspace);
      } catch (error: any) {
        return text({ error: error.message });
      }
    };
  server.registerTool(
    "update_workspace",
    {
      title: "Update Workspace",
      description: "Update workspace settings",
      inputSchema: {
        id: z.string().describe("Workspace ID"),
        public: z.boolean().optional().describe("Make workspace public"),
        enableAi: z.boolean().optional().describe("Enable AI features")
      }
    },
    updateWorkspaceHandler as any
  );
  server.registerTool(
    "affine_update_workspace",
    {
      title: "Update Workspace",
      description: "Update workspace settings",
      inputSchema: {
        id: z.string().describe("Workspace ID"),
        public: z.boolean().optional().describe("Make workspace public"),
        enableAi: z.boolean().optional().describe("Enable AI features")
      }
    },
    updateWorkspaceHandler as any
  );
  // DELETE WORKSPACE
  const deleteWorkspaceHandler = async ({ id }: { id: string }) => {
      try {
        const mutation = `
          mutation DeleteWorkspace($id: String!) {
            deleteWorkspace(id: $id)
          }
        `;
        
        const data = await gql.request<{ deleteWorkspace: boolean }>(mutation, { id });
        
        return text({ success: data.deleteWorkspace, message: "Workspace deleted successfully" });
      } catch (error: any) {
        return text({ error: error.message });
      }
    };
  server.registerTool(
    "delete_workspace",
    {
      title: "Delete Workspace",
      description: "Delete a workspace permanently",
      inputSchema: {
        id: z.string().describe("Workspace ID")
      }
    },
    deleteWorkspaceHandler as any
  );
  server.registerTool(
    "affine_delete_workspace",
    {
      title: "Delete Workspace",
      description: "Delete a workspace permanently",
      inputSchema: {
        id: z.string().describe("Workspace ID")
      }
    },
    deleteWorkspaceHandler as any
  );
}