feat: Initial release MCP Outline PostgreSQL v1.0.0

86 tools across 12 modules for direct PostgreSQL access to Outline Wiki:
- Documents (19), Collections (14), Users (9), Groups (8)
- Comments (6), Shares (5), Revisions (3), Events (3)
- Attachments (5), File Operations (4), OAuth (8), Auth (2)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 13:25:09 +00:00
commit b05b54033f
30 changed files with 14439 additions and 0 deletions

490
src/tools/attachments.ts Normal file
View File

@@ -0,0 +1,490 @@
/**
* MCP Outline PostgreSQL - Attachments Tools
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { Pool } from 'pg';
import { BaseTool, ToolResponse, CreateAttachmentArgs, GetAttachmentArgs, PaginationArgs } from '../types/tools.js';
import { validatePagination, isValidUUID } from '../utils/security.js';
interface AttachmentListArgs extends PaginationArgs {
document_id?: string;
user_id?: string;
team_id?: string;
}
/**
* attachments.list - List attachments with optional filters
*/
const listAttachments: BaseTool<AttachmentListArgs> = {
name: 'outline_attachments_list',
description: 'List file attachments with optional filtering by document, user, or team. Supports pagination.',
inputSchema: {
type: 'object',
properties: {
document_id: {
type: 'string',
description: 'Filter by document ID (UUID)',
},
user_id: {
type: 'string',
description: 'Filter by user ID who uploaded (UUID)',
},
team_id: {
type: 'string',
description: 'Filter by team 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[] = ['a."deletedAt" IS NULL'];
const params: any[] = [];
let paramIndex = 1;
if (args.document_id) {
if (!isValidUUID(args.document_id)) {
throw new Error('Invalid document_id format');
}
conditions.push(`a."documentId" = $${paramIndex++}`);
params.push(args.document_id);
}
if (args.user_id) {
if (!isValidUUID(args.user_id)) {
throw new Error('Invalid user_id format');
}
conditions.push(`a."userId" = $${paramIndex++}`);
params.push(args.user_id);
}
if (args.team_id) {
if (!isValidUUID(args.team_id)) {
throw new Error('Invalid team_id format');
}
conditions.push(`a."teamId" = $${paramIndex++}`);
params.push(args.team_id);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const query = `
SELECT
a.id,
a.key,
a.url,
a."contentType",
a.size,
a.acl,
a."documentId",
a."userId",
a."teamId",
a."createdAt",
a."updatedAt",
d.title as "documentTitle",
u.name as "uploadedByName",
u.email as "uploadedByEmail"
FROM attachments a
LEFT JOIN documents d ON a."documentId" = d.id
LEFT JOIN users u ON a."userId" = u.id
${whereClause}
ORDER BY a."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
),
},
],
};
},
};
/**
* attachments.info - Get detailed information about a specific attachment
*/
const getAttachment: BaseTool<GetAttachmentArgs> = {
name: 'outline_attachments_info',
description: 'Get detailed information about a specific attachment by ID.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Attachment ID (UUID)',
},
},
required: ['id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.id)) {
throw new Error('Invalid attachment ID format');
}
const query = `
SELECT
a.id,
a.key,
a.url,
a."contentType",
a.size,
a.acl,
a."documentId",
a."userId",
a."teamId",
a."createdAt",
a."updatedAt",
a."deletedAt",
d.title as "documentTitle",
d."collectionId",
u.name as "uploadedByName",
u.email as "uploadedByEmail",
t.name as "teamName"
FROM attachments a
LEFT JOIN documents d ON a."documentId" = d.id
LEFT JOIN users u ON a."userId" = u.id
LEFT JOIN teams t ON a."teamId" = t.id
WHERE a.id = $1
`;
const result = await pgClient.query(query, [args.id]);
if (result.rows.length === 0) {
throw new Error('Attachment not found');
}
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
data: result.rows[0],
},
null,
2
),
},
],
};
},
};
/**
* attachments.create - Create a new attachment record
*/
const createAttachment: BaseTool<CreateAttachmentArgs> = {
name: 'outline_attachments_create',
description: 'Create a new attachment record. Note: This creates the database record only, actual file upload is handled separately.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Attachment filename/key',
},
document_id: {
type: 'string',
description: 'Document ID to attach to (UUID, optional)',
},
content_type: {
type: 'string',
description: 'MIME type (e.g., "image/png", "application/pdf")',
},
size: {
type: 'number',
description: 'File size in bytes',
},
},
required: ['name', 'content_type', 'size'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (args.document_id && !isValidUUID(args.document_id)) {
throw new Error('Invalid document_id format');
}
// Verify document exists if provided
if (args.document_id) {
const docCheck = await pgClient.query(
'SELECT id, "teamId" 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');
}
}
// Get first admin user and team
const userQuery = await pgClient.query(
'SELECT u.id, u."teamId" FROM users u WHERE u."isAdmin" = true AND u."deletedAt" IS NULL LIMIT 1'
);
if (userQuery.rows.length === 0) {
throw new Error('No valid user found to create attachment');
}
const userId = userQuery.rows[0].id;
const teamId = userQuery.rows[0].teamId;
// Generate URL and key (in real implementation, this would be S3/storage URL)
const key = `attachments/${Date.now()}-${args.name}`;
const url = `/api/attachments.redirect?id=PLACEHOLDER`;
const query = `
INSERT INTO attachments (
key,
url,
"contentType",
size,
acl,
"documentId",
"userId",
"teamId",
"createdAt",
"updatedAt"
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING *
`;
const result = await pgClient.query(query, [
key,
url,
args.content_type,
args.size,
'private', // Default ACL
args.document_id || null,
userId,
teamId,
]);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
data: result.rows[0],
},
null,
2
),
},
],
};
},
};
/**
* attachments.delete - Delete an attachment (soft delete)
*/
const deleteAttachment: BaseTool<GetAttachmentArgs> = {
name: 'outline_attachments_delete',
description: 'Soft delete an attachment. The attachment record is marked as deleted but not removed from the database.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Attachment ID (UUID)',
},
},
required: ['id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.id)) {
throw new Error('Invalid attachment ID format');
}
const query = `
UPDATE attachments
SET
"deletedAt" = NOW(),
"updatedAt" = NOW()
WHERE id = $1 AND "deletedAt" IS NULL
RETURNING id, key, "documentId"
`;
const result = await pgClient.query(query, [args.id]);
if (result.rows.length === 0) {
throw new Error('Attachment not found or already deleted');
}
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
success: true,
message: 'Attachment deleted successfully',
data: result.rows[0],
},
null,
2
),
},
],
};
},
};
/**
* attachments.stats - Get attachment statistics
*/
const getAttachmentStats: BaseTool<{ team_id?: string; document_id?: string }> = {
name: 'outline_attachments_stats',
description: 'Get statistics about attachments including total count, size, and breakdown by content type.',
inputSchema: {
type: 'object',
properties: {
team_id: {
type: 'string',
description: 'Filter statistics by team ID (UUID)',
},
document_id: {
type: 'string',
description: 'Filter statistics by document ID (UUID)',
},
},
},
handler: async (args, pgClient): Promise<ToolResponse> => {
const conditions: string[] = ['a."deletedAt" IS NULL'];
const params: any[] = [];
let paramIndex = 1;
if (args.team_id) {
if (!isValidUUID(args.team_id)) {
throw new Error('Invalid team_id format');
}
conditions.push(`a."teamId" = $${paramIndex++}`);
params.push(args.team_id);
}
if (args.document_id) {
if (!isValidUUID(args.document_id)) {
throw new Error('Invalid document_id format');
}
conditions.push(`a."documentId" = $${paramIndex++}`);
params.push(args.document_id);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
// Overall statistics
const overallStatsQuery = await pgClient.query(
`SELECT
COUNT(*) as "totalAttachments",
SUM(size) as "totalSize",
AVG(size) as "averageSize",
COUNT(DISTINCT "documentId") as "documentsWithAttachments",
COUNT(DISTINCT "userId") as "uniqueUploaders"
FROM attachments a
${whereClause}`,
params
);
// By content type
const byContentTypeQuery = await pgClient.query(
`SELECT
a."contentType",
COUNT(*) as count,
SUM(size) as "totalSize"
FROM attachments a
${whereClause}
GROUP BY a."contentType"
ORDER BY count DESC`,
params
);
// Top uploaders
const topUploadersQuery = await pgClient.query(
`SELECT
a."userId",
u.name as "userName",
u.email as "userEmail",
COUNT(*) as "attachmentCount",
SUM(a.size) as "totalSize"
FROM attachments a
LEFT JOIN users u ON a."userId" = u.id
${whereClause}
GROUP BY a."userId", u.name, u.email
ORDER BY "attachmentCount" DESC
LIMIT 10`,
params
);
// Recent uploads
const recentUploadsQuery = await pgClient.query(
`SELECT
a.id,
a.key,
a."contentType",
a.size,
a."createdAt",
u.name as "uploadedByName",
d.title as "documentTitle"
FROM attachments a
LEFT JOIN users u ON a."userId" = u.id
LEFT JOIN documents d ON a."documentId" = d.id
${whereClause}
ORDER BY a."createdAt" DESC
LIMIT 10`,
params
);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
overall: overallStatsQuery.rows[0],
byContentType: byContentTypeQuery.rows,
topUploaders: topUploadersQuery.rows,
recentUploads: recentUploadsQuery.rows,
},
null,
2
),
},
],
};
},
};
// Export all attachment tools
export const attachmentsTools: BaseTool<any>[] = [
listAttachments,
getAttachment,
createAttachment,
deleteAttachment,
getAttachmentStats,
];