/** * MCP Outline PostgreSQL - Comments Tools * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { Pool } from 'pg'; import { BaseTool, ToolResponse, CommentArgs, GetCommentArgs, CreateCommentArgs, UpdateCommentArgs } from '../types/tools.js'; import { validatePagination, isValidUUID } from '../utils/security.js'; /** * comments.list - List comments with optional filters */ const listComments: BaseTool = { name: 'outline_comments_list', description: 'List comments with optional filtering by document or collection. Supports pagination.', inputSchema: { type: 'object', properties: { document_id: { type: 'string', description: 'Filter by document ID (UUID)', }, collection_id: { type: 'string', description: 'Filter by collection ID (UUID)', }, limit: { type: 'number', description: 'Maximum number of results (default: 25, max: 100)', }, offset: { type: 'number', description: 'Number of results to skip (default: 0)', }, }, }, handler: async (args, pgClient): Promise => { const { limit, offset } = validatePagination(args.limit, args.offset); const conditions: string[] = []; const params: any[] = []; let paramIndex = 1; if (args.document_id) { if (!isValidUUID(args.document_id)) { throw new Error('Invalid document_id format'); } conditions.push(`c."documentId" = $${paramIndex++}`); params.push(args.document_id); } if (args.collection_id) { if (!isValidUUID(args.collection_id)) { throw new Error('Invalid collection_id format'); } conditions.push(`d."collectionId" = $${paramIndex++}`); params.push(args.collection_id); } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const query = ` SELECT c.id, c.data, c."documentId", c."parentCommentId", c."createdById", c."resolvedById", c."resolvedAt", c."createdAt", c."updatedAt", u.name as "createdByName", u.email as "createdByEmail", ru.name as "resolvedByName", d.title as "documentTitle" FROM comments c LEFT JOIN users u ON c."createdById" = u.id LEFT JOIN users ru ON c."resolvedById" = ru.id LEFT JOIN documents d ON c."documentId" = d.id ${whereClause} ORDER BY c."createdAt" DESC LIMIT $${paramIndex++} OFFSET $${paramIndex} `; params.push(limit, offset); const result = await pgClient.query(query, params); return { content: [ { type: 'text', text: JSON.stringify( { data: result.rows, pagination: { limit, offset, total: result.rows.length, }, }, null, 2 ), }, ], }; }, }; /** * comments.info - Get detailed information about a specific comment */ const getComment: BaseTool = { name: 'outline_comments_info', description: 'Get detailed information about a specific comment by ID.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Comment ID (UUID)', }, }, required: ['id'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.id)) { throw new Error('Invalid comment ID format'); } const query = ` SELECT c.id, c.data, c."documentId", c."parentCommentId", c."createdById", c."resolvedById", c."resolvedAt", c."createdAt", c."updatedAt", u.name as "createdByName", u.email as "createdByEmail", ru.name as "resolvedByName", d.title as "documentTitle", d."collectionId" FROM comments c LEFT JOIN users u ON c."createdById" = u.id LEFT JOIN users ru ON c."resolvedById" = ru.id LEFT JOIN documents d ON c."documentId" = d.id WHERE c.id = $1 `; const result = await pgClient.query(query, [args.id]); if (result.rows.length === 0) { throw new Error('Comment not found'); } // Get replies if this is a parent comment const repliesQuery = ` SELECT c.id, c.data, c."createdById", c."createdAt", u.name as "createdByName" FROM comments c LEFT JOIN users u ON c."createdById" = u.id WHERE c."parentCommentId" = $1 ORDER BY c."createdAt" ASC `; const replies = await pgClient.query(repliesQuery, [args.id]); return { content: [ { type: 'text', text: JSON.stringify( { data: { ...result.rows[0], replies: replies.rows, }, }, null, 2 ), }, ], }; }, }; /** * comments.create - Create a new comment */ const createComment: BaseTool = { name: 'outline_comments_create', description: 'Create a new comment on a document. Can be a top-level comment or a reply to another comment.', inputSchema: { type: 'object', properties: { document_id: { type: 'string', description: 'Document ID (UUID)', }, data: { type: 'object', description: 'Comment data (JSON object with content)', }, parent_comment_id: { type: 'string', description: 'Parent comment ID for replies (UUID, optional)', }, }, required: ['document_id', 'data'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.document_id)) { throw new Error('Invalid document_id format'); } if (args.parent_comment_id && !isValidUUID(args.parent_comment_id)) { throw new Error('Invalid parent_comment_id format'); } // Verify document exists const docCheck = await pgClient.query( 'SELECT id FROM documents WHERE id = $1 AND "deletedAt" IS NULL', [args.document_id] ); if (docCheck.rows.length === 0) { throw new Error('Document not found or deleted'); } // Verify parent comment exists if provided if (args.parent_comment_id) { const parentCheck = await pgClient.query( 'SELECT id FROM comments WHERE id = $1 AND "documentId" = $2', [args.parent_comment_id, args.document_id] ); if (parentCheck.rows.length === 0) { throw new Error('Parent comment not found or not on the same document'); } } // Note: In real implementation, createdById should come from authentication context // For now, we'll get the first admin user const userQuery = await pgClient.query( `SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1` ); if (userQuery.rows.length === 0) { throw new Error('No valid user found to create comment'); } const createdById = userQuery.rows[0].id; const query = ` INSERT INTO comments ( "documentId", "data", "parentCommentId", "createdById", "createdAt", "updatedAt" ) VALUES ($1, $2, $3, $4, NOW(), NOW()) RETURNING * `; const result = await pgClient.query(query, [ args.document_id, JSON.stringify(args.data), args.parent_comment_id || null, createdById, ]); return { content: [ { type: 'text', text: JSON.stringify( { data: result.rows[0], }, null, 2 ), }, ], }; }, }; /** * comments.update - Update an existing comment */ const updateComment: BaseTool = { name: 'outline_comments_update', description: 'Update the content of an existing comment.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Comment ID (UUID)', }, data: { type: 'object', description: 'Updated comment data (JSON object)', }, }, required: ['id', 'data'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.id)) { throw new Error('Invalid comment ID format'); } const query = ` UPDATE comments SET "data" = $1, "updatedAt" = NOW() WHERE id = $2 RETURNING * `; const result = await pgClient.query(query, [JSON.stringify(args.data), args.id]); if (result.rows.length === 0) { throw new Error('Comment not found'); } return { content: [ { type: 'text', text: JSON.stringify( { data: result.rows[0], }, null, 2 ), }, ], }; }, }; /** * comments.delete - Delete a comment */ const deleteComment: BaseTool = { name: 'outline_comments_delete', description: 'Delete a comment. This will also delete all replies to this comment.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Comment ID (UUID)', }, }, required: ['id'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.id)) { throw new Error('Invalid comment ID format'); } // Delete replies first await pgClient.query('DELETE FROM comments WHERE "parentCommentId" = $1', [args.id]); // Delete the comment const result = await pgClient.query('DELETE FROM comments WHERE id = $1 RETURNING id', [args.id]); if (result.rows.length === 0) { throw new Error('Comment not found'); } return { content: [ { type: 'text', text: JSON.stringify( { success: true, message: 'Comment deleted successfully', id: result.rows[0].id, }, null, 2 ), }, ], }; }, }; /** * comments.resolve - Mark a comment as resolved */ const resolveComment: BaseTool = { name: 'outline_comments_resolve', description: 'Mark a comment as resolved. Can also be used to unresolve a comment by setting resolved to false.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Comment ID (UUID)', }, }, required: ['id'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.id)) { throw new Error('Invalid comment ID format'); } // Get first admin user as resolver const userQuery = await pgClient.query( `SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1` ); if (userQuery.rows.length === 0) { throw new Error('No valid user found to resolve comment'); } const resolvedById = userQuery.rows[0].id; const query = ` UPDATE comments SET "resolvedById" = $1, "resolvedAt" = NOW(), "updatedAt" = NOW() WHERE id = $2 RETURNING * `; const result = await pgClient.query(query, [resolvedById, args.id]); if (result.rows.length === 0) { throw new Error('Comment not found'); } return { content: [ { type: 'text', text: JSON.stringify( { data: result.rows[0], }, null, 2 ), }, ], }; }, }; // Export all comment tools export const commentsTools: BaseTool[] = [ listComments, getComment, createComment, updateComment, deleteComment, resolveComment, ];