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

370
src/tools/events.ts Normal file
View File

@@ -0,0 +1,370 @@
/**
* MCP Outline PostgreSQL - Events Tools
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { Pool } from 'pg';
import { BaseTool, ToolResponse, EventArgs } from '../types/tools.js';
import { validatePagination, isValidUUID } from '../utils/security.js';
/**
* events.list - List events with optional filters
*/
const listEvents: BaseTool<EventArgs> = {
name: 'outline_events_list',
description: 'List system events with optional filtering by name, actor, document, collection, or date range. Useful for audit logs and activity tracking.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Filter by event name (e.g., "documents.create", "users.signin")',
},
actor_id: {
type: 'string',
description: 'Filter by actor user ID (UUID)',
},
document_id: {
type: 'string',
description: 'Filter by document ID (UUID)',
},
collection_id: {
type: 'string',
description: 'Filter by collection ID (UUID)',
},
date_from: {
type: 'string',
description: 'Filter events from this date (ISO 8601 format)',
},
date_to: {
type: 'string',
description: 'Filter events until this date (ISO 8601 format)',
},
audit_log: {
type: 'boolean',
description: 'Filter only audit log worthy events (default: false)',
},
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.name) {
conditions.push(`e.name = $${paramIndex++}`);
params.push(args.name);
}
if (args.actor_id) {
if (!isValidUUID(args.actor_id)) {
throw new Error('Invalid actor_id format');
}
conditions.push(`e."actorId" = $${paramIndex++}`);
params.push(args.actor_id);
}
if (args.document_id) {
if (!isValidUUID(args.document_id)) {
throw new Error('Invalid document_id format');
}
conditions.push(`e."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(`e."collectionId" = $${paramIndex++}`);
params.push(args.collection_id);
}
if (args.date_from) {
conditions.push(`e."createdAt" >= $${paramIndex++}`);
params.push(args.date_from);
}
if (args.date_to) {
conditions.push(`e."createdAt" <= $${paramIndex++}`);
params.push(args.date_to);
}
// Audit log filter - common audit events
if (args.audit_log) {
conditions.push(`e.name IN (
'users.create', 'users.update', 'users.delete', 'users.signin',
'documents.create', 'documents.update', 'documents.delete', 'documents.publish',
'collections.create', 'collections.update', 'collections.delete',
'groups.create', 'groups.update', 'groups.delete',
'shares.create', 'shares.revoke'
)`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const query = `
SELECT
e.id,
e.name,
e."modelId",
e."actorId",
e."userId",
e."collectionId",
e."documentId",
e."teamId",
e.ip,
e.data,
e."createdAt",
actor.name as "actorName",
actor.email as "actorEmail",
u.name as "userName",
c.name as "collectionName",
d.title as "documentTitle"
FROM events e
LEFT JOIN users actor ON e."actorId" = actor.id
LEFT JOIN users u ON e."userId" = u.id
LEFT JOIN collections c ON e."collectionId" = c.id
LEFT JOIN documents d ON e."documentId" = d.id
${whereClause}
ORDER BY e."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
),
},
],
};
},
};
/**
* events.info - Get detailed information about a specific event
*/
const getEvent: BaseTool<{ id: string }> = {
name: 'outline_events_info',
description: 'Get detailed information about a specific event by ID, including all associated metadata.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Event ID (UUID)',
},
},
required: ['id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.id)) {
throw new Error('Invalid event ID format');
}
const query = `
SELECT
e.id,
e.name,
e."modelId",
e."actorId",
e."userId",
e."collectionId",
e."documentId",
e."teamId",
e.ip,
e.data,
e."createdAt",
actor.name as "actorName",
actor.email as "actorEmail",
actor."isAdmin" as "actorIsAdmin",
u.name as "userName",
u.email as "userEmail",
c.name as "collectionName",
d.title as "documentTitle",
t.name as "teamName"
FROM events e
LEFT JOIN users actor ON e."actorId" = actor.id
LEFT JOIN users u ON e."userId" = u.id
LEFT JOIN collections c ON e."collectionId" = c.id
LEFT JOIN documents d ON e."documentId" = d.id
LEFT JOIN teams t ON e."teamId" = t.id
WHERE e.id = $1
`;
const result = await pgClient.query(query, [args.id]);
if (result.rows.length === 0) {
throw new Error('Event not found');
}
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
data: result.rows[0],
},
null,
2
),
},
],
};
},
};
/**
* events.stats - Get event statistics and summaries
*/
const getEventStats: BaseTool<EventArgs> = {
name: 'outline_events_stats',
description: 'Get statistical analysis of events. Provides event counts by type, top actors, and activity trends.',
inputSchema: {
type: 'object',
properties: {
date_from: {
type: 'string',
description: 'Statistics from this date (ISO 8601 format)',
},
date_to: {
type: 'string',
description: 'Statistics until this date (ISO 8601 format)',
},
collection_id: {
type: 'string',
description: 'Filter statistics by collection ID (UUID)',
},
},
},
handler: async (args, pgClient): Promise<ToolResponse> => {
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (args.date_from) {
conditions.push(`e."createdAt" >= $${paramIndex++}`);
params.push(args.date_from);
}
if (args.date_to) {
conditions.push(`e."createdAt" <= $${paramIndex++}`);
params.push(args.date_to);
}
if (args.collection_id) {
if (!isValidUUID(args.collection_id)) {
throw new Error('Invalid collection_id format');
}
conditions.push(`e."collectionId" = $${paramIndex++}`);
params.push(args.collection_id);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Event counts by type
const eventsByTypeQuery = await pgClient.query(
`SELECT
e.name,
COUNT(*) as count
FROM events e
${whereClause}
GROUP BY e.name
ORDER BY count DESC
LIMIT 20`,
params
);
// Top actors
const topActorsQuery = await pgClient.query(
`SELECT
e."actorId",
u.name as "actorName",
u.email as "actorEmail",
COUNT(*) as "eventCount"
FROM events e
LEFT JOIN users u ON e."actorId" = u.id
${whereClause}
GROUP BY e."actorId", u.name, u.email
ORDER BY "eventCount" DESC
LIMIT 10`,
params
);
// Activity by day (last 30 days or filtered range)
const activityByDayQuery = await pgClient.query(
`SELECT
DATE(e."createdAt") as date,
COUNT(*) as "eventCount"
FROM events e
${whereClause}
GROUP BY DATE(e."createdAt")
ORDER BY date DESC
LIMIT 30`,
params
);
// Total statistics
const totalsQuery = await pgClient.query(
`SELECT
COUNT(*) as "totalEvents",
COUNT(DISTINCT e."actorId") as "uniqueActors",
COUNT(DISTINCT e."documentId") as "affectedDocuments",
COUNT(DISTINCT e."collectionId") as "affectedCollections"
FROM events e
${whereClause}`,
params
);
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
totals: totalsQuery.rows[0],
eventsByType: eventsByTypeQuery.rows,
topActors: topActorsQuery.rows,
activityByDay: activityByDayQuery.rows,
},
null,
2
),
},
],
};
},
};
// Export all event tools
export const eventsTools: BaseTool<any>[] = [
listEvents,
getEvent,
getEventStats,
];