- auth.ts: Use suspendedAt instead of isSuspended, role instead of isAdmin - comments.ts: Use role='admin' for admin user queries - documents.ts: Use suspendedAt IS NULL for active users - events.ts: Return actorRole instead of actorIsAdmin - shares.ts: Use role='admin' for admin user queries All queries validated against Outline v0.78 schema (10/10 tests pass). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
481 lines
12 KiB
TypeScript
481 lines
12 KiB
TypeScript
/**
|
|
* 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<CommentArgs> = {
|
|
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<ToolResponse> => {
|
|
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<GetCommentArgs> = {
|
|
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<ToolResponse> => {
|
|
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<CreateCommentArgs> = {
|
|
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<ToolResponse> => {
|
|
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<UpdateCommentArgs> = {
|
|
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<ToolResponse> => {
|
|
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<GetCommentArgs> = {
|
|
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<ToolResponse> => {
|
|
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<GetCommentArgs> = {
|
|
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<ToolResponse> => {
|
|
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<any>[] = [
|
|
listComments,
|
|
getComment,
|
|
createComment,
|
|
updateComment,
|
|
deleteComment,
|
|
resolveComment,
|
|
];
|