Files
mcp-outline-postgresql/src/tools/events.ts
Emanuel Almeida 7116722d73 fix: Complete schema adaptation for all tool modules
- 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>
2026-01-31 13:34:53 +00:00

371 lines
9.7 KiB
TypeScript

/**
* 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.role as "actorRole",
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,
];