pagespace-mcp
Version:
Model Context Protocol (MCP) server for PageSpace with complete workspace management, powerful search, batch operations, and AI agent capabilities. Provides external access to all core PageSpace functionality.
327 lines (317 loc) ⢠12.7 kB
JavaScript
/**
* @param {import('../api.js').PageSpaceApi} api
*/
export function createPageHandler(api) {
return {
async handleListPages(args) {
console.error('[MCP] handleListPages called')
try {
const { driveSlug, driveId } = args
if (!driveId || !driveSlug) {
return {
content: [{ type: 'text', text: `ā Error: Both driveSlug and driveId are required!\n\nUse list_drives first to see available drives, then use list_pages with both parameters.` }],
}
}
const drives = await api.makeAuthenticatedRequest('/api/drives', {
method: 'GET',
})
if (drives.error) {
throw new Error(drives.error)
}
const drive = drives.find(d => d.id === driveId)
if (!drive) {
throw new Error(`Drive with ID ${driveId} not found`)
}
const pages = await api.makeAuthenticatedRequest(`/api/drives/${driveId}/pages`, {
method: 'GET',
})
if (pages.error) {
throw new Error(pages.error)
}
let output = `# š Page List for Drive: "${drive.name}" (${driveSlug})\n\n`
if (!pages || pages.length === 0) {
output += "No pages found in this drive.\n"
return {
content: [{ type: 'text', text: output }],
}
}
const paths = this.flattenPagePaths(pages, driveSlug)
paths.forEach(path => {
output += `${path}\n`
})
output += "\nš” **Next Steps:**\n"
output += "1. Use `read_page` with a path from above to read any document\n"
output += "2. After reading, use edit tools (replace_lines, insert_lines, etc.) with paths to make changes\n"
output += "3. Use `create_page` with a parent path to create new pages\n"
return {
content: [{ type: 'text', text: output }],
}
} catch (error) {
console.error('[MCP] handleListPages error:', error)
return {
content: [{ type: 'text', text: `Error reading drive tree: ${error.message}` }],
}
}
},
async handleCreatePage(args) {
console.error('[MCP] handleCreatePage called')
try {
const { driveId, parentId, title, type, content } = args
if (!driveId || !title || !type) {
return {
content: [{ type: 'text', text: `ā Error: driveId, title, and type are required!\n\nUse list_drives to get driveId and list_pages to get parentId (optional for root level).` }],
}
}
const result = await api.makeAuthenticatedRequest('/api/pages', {
method: 'POST',
body: JSON.stringify({
driveId,
title,
type,
parentId: parentId || null,
content: content || '',
}),
})
if (result.error) {
throw new Error(result.error)
}
return {
content: [{ type: 'text', text: `ā
Successfully created ${type.toLowerCase()} page: "${result.title}"\n\nš Page ID: ${result.id}\nš Drive ID: ${driveId}\nš Parent ID: ${parentId || 'root level'}\n\nš” Use list_pages to see the updated page structure.` }],
}
} catch (error) {
console.error('[MCP] handleCreatePage error:', error)
return {
content: [{ type: 'text', text: `ā Error creating page: ${error.message}` }],
}
}
},
async handleRenamePage(args) {
console.error('[MCP] handleRenamePage called')
try {
const { pageId, title } = args
if (!pageId) {
return {
content: [{ type: 'text', text: `ā Error: pageId is required!\n\nUse list_pages to get page IDs.` }],
}
}
if (!title) {
return {
content: [{ type: 'text', text: `ā Error: title is required for rename operation.` }],
}
}
const result = await api.makeAuthenticatedRequest(`/api/pages/${pageId}`, {
method: 'PATCH',
body: JSON.stringify({ title }),
})
if (result.error) {
throw new Error(result.error)
}
return {
content: [{ type: 'text', text: `ā
Successfully renamed page to "${result.title}" (ID: ${pageId})\n\nš Type: ${result.type}\n\nš” Use list_pages to see the updated page structure.` }],
}
} catch (error) {
console.error('[MCP] handleRenamePage error:', error)
return {
content: [{ type: 'text', text: `ā Error renaming page: ${error.message}` }],
}
}
},
async handleTrashPage(args) {
console.error('[MCP] handleTrashPage called')
try {
const { pageId } = args
if (!pageId) {
return {
content: [{ type: 'text', text: `ā Error: pageId is required!\n\nUse list_pages to get page IDs.` }],
}
}
const result = await api.makeAuthenticatedRequest(`/api/pages/${pageId}`, {
method: 'DELETE',
body: JSON.stringify({
trash_children: false,
}),
})
if (result.error) {
throw new Error(result.error)
}
const childrenNote = '' // This method only trashes the single page
return {
content: [{ type: 'text', text: `ā
Successfully moved page to trash${childrenNote}\n\nš Page ID: ${pageId}\nšļø Status: Trashed (not permanently deleted)\n\nš” Use list_trash to see trashed pages or restore_page to restore it.` }],
}
} catch (error) {
console.error('[MCP] handleTrashPage error:', error)
return {
content: [{ type: 'text', text: `ā Error trashing page: ${error.message}` }],
}
}
},
async handleTrashPageWithChildren(args) {
console.error('[MCP] handleTrashPageWithChildren called')
try {
const { pageId } = args
if (!pageId) {
return {
content: [{ type: 'text', text: `ā Error: pageId is required!\n\nUse list_pages to get page IDs.` }],
}
}
const result = await api.makeAuthenticatedRequest(`/api/pages/${pageId}`, {
method: 'DELETE',
body: JSON.stringify({
trash_children: true,
}),
})
if (result.error) {
throw new Error(result.error)
}
return {
content: [{ type: 'text', text: `ā
Successfully moved page and all children to trash\n\nš Page ID: ${pageId}\nšļø Status: Trashed (including all child pages)\n\nš” Use list_trash to see trashed pages or restore_page to restore it.` }],
}
} catch (error) {
console.error('[MCP] handleTrashPageWithChildren error:', error)
return {
content: [{ type: 'text', text: `ā Error trashing page with children: ${error.message}` }],
}
}
},
async handleRestorePage(args) {
console.error('[MCP] handleRestorePage called')
try {
const { pageId } = args
if (!pageId) {
return {
content: [{ type: 'text', text: `ā Error: pageId is required!\n\nUse list_trash first to see trashed pages and their IDs.` }],
}
}
const result = await api.makeAuthenticatedRequest(`/api/pages/${pageId}/restore`, {
method: 'POST',
})
if (result.error) {
throw new Error(result.error)
}
return {
content: [{ type: 'text', text: `ā
Successfully restored page from trash\n\nš Page ID: ${pageId}\nā»ļø Status: Restored to original location\n\nš” Use list_pages to see the restored page structure.` }],
}
} catch (error) {
console.error('[MCP] handleRestorePage error:', error)
return {
content: [{ type: 'text', text: `ā Error restoring page: ${error.message}` }],
}
}
},
async handleMovePage(args) {
console.error('[MCP] handleMovePage called')
try {
const { pageId, newParentId, position } = args
if (!pageId || !position) {
return {
content: [{ type: 'text', text: `ā Error: pageId and position are required!\n\nUse list_pages to get page IDs.` }],
}
}
const result = await api.makeAuthenticatedRequest('/api/pages/reorder', {
method: 'PATCH',
body: JSON.stringify({
pageId,
newParentId: newParentId || null,
newPosition: position,
}),
})
if (result.error) {
throw new Error(result.error)
}
const parentNote = newParentId ? `under parent ID ${newParentId}` : 'to root level'
return {
content: [{ type: 'text', text: `ā
Successfully moved page ${parentNote} at position ${position}\n\nš Page ID: ${pageId}\nš Parent ID: ${newParentId || 'root level'}\nš Position: ${position}\n\nš” Use list_pages to see the updated page structure.` }],
}
} catch (error) {
console.error('[MCP] handleMovePage error:', error)
return {
content: [{ type: 'text', text: `ā Error moving page: ${error.message}` }],
}
}
},
async handleListTrash(args) {
console.error('[MCP] handleListTrash called')
try {
const { driveSlug, driveId } = args
if (!driveId || !driveSlug) {
return {
content: [{ type: 'text', text: `ā Error: Both driveSlug and driveId are required!\n\nUse list_drives first to see available drives, then use list_trash with both parameters.` }],
}
}
const drives = await api.makeAuthenticatedRequest('/api/drives', {
method: 'GET',
})
if (drives.error) {
throw new Error(drives.error)
}
const drive = drives.find(d => d.id === driveId)
if (!drive) {
throw new Error(`Drive with ID ${driveId} not found`)
}
const trashedPages = await api.makeAuthenticatedRequest(`/api/drives/${driveId}/trash`, {
method: 'GET',
})
if (trashedPages.error) {
throw new Error(trashedPages.error)
}
let trashOutput = `# šļø Trash for Drive: "${drive.name}" (${driveSlug})\n\n`
if (!trashedPages || trashedPages.length === 0) {
trashOutput += "No pages in trash.\n"
return {
content: [{ type: 'text', text: trashOutput }],
}
}
trashOutput += `Found ${trashedPages.length} trashed page(s):\n\n`
trashOutput += this.formatTrashTree(trashedPages, "")
trashOutput += "\nš” **Next Steps:**\n"
trashOutput += "1. Use `restore_page` with a title from above to restore any page\n"
trashOutput += "2. Restored pages will return to their original location\n"
return {
content: [{ type: 'text', text: trashOutput }],
}
} catch (error) {
console.error('[MCP] handleListTrash error:', error)
return {
content: [{ type: 'text', text: `ā Error listing trash: ${error.message}` }],
}
}
},
flattenPagePaths(nodes, driveSlug, parentPath = "") {
const paths = []
nodes.forEach(node => {
const currentPath = parentPath ? `/${driveSlug}/${parentPath}/${node.title}` : `/${driveSlug}/${node.title}`
const typeIcon = this.getTypeIcon(node.type)
paths.push(`${typeIcon} ID: ${node.id} Path: ${currentPath}`)
if (node.children && node.children.length > 0) {
const newParentPath = parentPath ? `${parentPath}/${node.title}` : node.title
paths.push(...this.flattenPagePaths(node.children, driveSlug, newParentPath))
}
})
return paths
},
formatTrashTree(nodes, indent = "") {
let output = ""
nodes.forEach((node, index) => {
const isLast = index === nodes.length - 1
const connector = isLast ? "āāā " : "āāā "
const typeIcon = this.getTypeIcon(node.type)
output += `${indent}${connector}${typeIcon} ID: ${node.id} Title: ${node.title}\n`
if (node.children && node.children.length > 0) {
const childIndent = indent + (isLast ? " " : "ā ")
output += this.formatTrashTree(node.children, childIndent)
}
})
return output
},
getTypeIcon(type) {
switch (type) {
case 'FOLDER': return 'š'
case 'DOCUMENT': return 'š'
case 'CHANNEL': return 'š¬'
case 'AI_CHAT': return 'š¤'
case 'CANVAS': return 'šØ'
default: return 'š'
}
},
}
}