feat: Add 52 new tools bringing total to 160
New modules (11): - teams.ts (5 tools): Team/workspace management - integrations.ts (6 tools): External integrations (Slack, embeds) - notifications.ts (4 tools): User notification management - subscriptions.ts (4 tools): Document subscription management - templates.ts (5 tools): Document template management - imports-tools.ts (4 tools): Import job management - emojis.ts (3 tools): Custom emoji management - user-permissions.ts (3 tools): Permission management - bulk-operations.ts (6 tools): Batch operations - advanced-search.ts (6 tools): Faceted search, recent, orphaned, duplicates - analytics.ts (6 tools): Usage statistics and insights Updated: - src/index.ts: Import and register all new tools - src/tools/index.ts: Export all new modules - CHANGELOG.md: Version 1.2.0 entry - CLAUDE.md: Updated tool count to 160 - CONTINUE.md: Updated state documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
52
src/index.ts
52
src/index.ts
@@ -41,7 +41,19 @@ import {
|
||||
apiKeysTools,
|
||||
webhooksTools,
|
||||
backlinksTools,
|
||||
searchQueriesTools
|
||||
searchQueriesTools,
|
||||
// New modules
|
||||
teamsTools,
|
||||
integrationsTools,
|
||||
notificationsTools,
|
||||
subscriptionsTools,
|
||||
templatesTools,
|
||||
importsTools,
|
||||
emojisTools,
|
||||
userPermissionsTools,
|
||||
bulkOperationsTools,
|
||||
advancedSearchTools,
|
||||
analyticsTools
|
||||
} from './tools/index.js';
|
||||
|
||||
dotenv.config();
|
||||
@@ -77,10 +89,31 @@ const allTools: BaseTool[] = [
|
||||
// API & Integration
|
||||
...apiKeysTools,
|
||||
...webhooksTools,
|
||||
...integrationsTools,
|
||||
|
||||
// Analytics
|
||||
// Analytics & Search
|
||||
...backlinksTools,
|
||||
...searchQueriesTools
|
||||
...searchQueriesTools,
|
||||
...advancedSearchTools,
|
||||
...analyticsTools,
|
||||
|
||||
// Teams & Workspace
|
||||
...teamsTools,
|
||||
|
||||
// Notifications & Subscriptions
|
||||
...notificationsTools,
|
||||
...subscriptionsTools,
|
||||
|
||||
// Templates & Imports
|
||||
...templatesTools,
|
||||
...importsTools,
|
||||
|
||||
// Custom content
|
||||
...emojisTools,
|
||||
|
||||
// Permissions & Bulk operations
|
||||
...userPermissionsTools,
|
||||
...bulkOperationsTools
|
||||
];
|
||||
|
||||
// Validate all tools have required properties
|
||||
@@ -215,7 +248,18 @@ async function main() {
|
||||
apiKeys: apiKeysTools.length,
|
||||
webhooks: webhooksTools.length,
|
||||
backlinks: backlinksTools.length,
|
||||
searchQueries: searchQueriesTools.length
|
||||
searchQueries: searchQueriesTools.length,
|
||||
teams: teamsTools.length,
|
||||
integrations: integrationsTools.length,
|
||||
notifications: notificationsTools.length,
|
||||
subscriptions: subscriptionsTools.length,
|
||||
templates: templatesTools.length,
|
||||
imports: importsTools.length,
|
||||
emojis: emojisTools.length,
|
||||
userPermissions: userPermissionsTools.length,
|
||||
bulkOperations: bulkOperationsTools.length,
|
||||
advancedSearch: advancedSearchTools.length,
|
||||
analytics: analyticsTools.length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
425
src/tools/advanced-search.ts
Normal file
425
src/tools/advanced-search.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Advanced Search Tools
|
||||
* Full-text search, filters, faceted search
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js';
|
||||
|
||||
interface AdvancedSearchArgs extends PaginationArgs {
|
||||
query: string;
|
||||
collection_ids?: string[];
|
||||
user_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
include_archived?: boolean;
|
||||
template?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* search.documents_advanced - Advanced document search
|
||||
*/
|
||||
const advancedSearchDocuments: BaseTool<AdvancedSearchArgs> = {
|
||||
name: 'outline_search_documents_advanced',
|
||||
description: 'Advanced full-text search with filters for documents.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Search query (full-text search)' },
|
||||
collection_ids: { type: 'array', items: { type: 'string' }, description: 'Filter by collection IDs (UUIDs)' },
|
||||
user_id: { type: 'string', description: 'Filter by author user ID (UUID)' },
|
||||
date_from: { type: 'string', description: 'Filter from date (ISO format)' },
|
||||
date_to: { type: 'string', description: 'Filter to date (ISO format)' },
|
||||
include_archived: { type: 'boolean', description: 'Include archived documents (default: false)' },
|
||||
template: { type: 'boolean', description: 'Filter templates only or exclude templates' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = ['d."deletedAt" IS NULL'];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
// Full-text search
|
||||
const searchQuery = sanitizeInput(args.query);
|
||||
conditions.push(`(d.title ILIKE $${idx} OR d.text ILIKE $${idx})`);
|
||||
params.push(`%${searchQuery}%`);
|
||||
idx++;
|
||||
|
||||
if (args.collection_ids && args.collection_ids.length > 0) {
|
||||
for (const id of args.collection_ids) {
|
||||
if (!isValidUUID(id)) throw new Error(`Invalid collection ID: ${id}`);
|
||||
}
|
||||
conditions.push(`d."collectionId" = ANY($${idx++})`);
|
||||
params.push(args.collection_ids);
|
||||
}
|
||||
|
||||
if (args.user_id) {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
conditions.push(`d."createdById" = $${idx++}`);
|
||||
params.push(args.user_id);
|
||||
}
|
||||
|
||||
if (args.date_from) {
|
||||
conditions.push(`d."createdAt" >= $${idx++}`);
|
||||
params.push(args.date_from);
|
||||
}
|
||||
|
||||
if (args.date_to) {
|
||||
conditions.push(`d."createdAt" <= $${idx++}`);
|
||||
params.push(args.date_to);
|
||||
}
|
||||
|
||||
if (!args.include_archived) {
|
||||
conditions.push(`d."archivedAt" IS NULL`);
|
||||
}
|
||||
|
||||
if (args.template !== undefined) {
|
||||
conditions.push(`d.template = $${idx++}`);
|
||||
params.push(args.template);
|
||||
}
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
d.id, d.title, d.emoji, d.template,
|
||||
d."collectionId", d."createdById",
|
||||
d."createdAt", d."updatedAt", d."publishedAt", d."archivedAt",
|
||||
c.name as "collectionName",
|
||||
u.name as "createdByName",
|
||||
SUBSTRING(d.text, 1, 200) as "textPreview"
|
||||
FROM documents d
|
||||
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||
LEFT JOIN users u ON d."createdById" = u.id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY d."updatedAt" DESC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
data: result.rows,
|
||||
query: args.query,
|
||||
pagination: { limit, offset, total: result.rows.length }
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* search.facets - Get search facets/filters
|
||||
*/
|
||||
const getSearchFacets: BaseTool<{ query?: string }> = {
|
||||
name: 'outline_get_search_facets',
|
||||
description: 'Get available search facets (collections, users, date ranges) for filtering.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', description: 'Optional query to narrow facets' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const baseCondition = args.query
|
||||
? `WHERE (d.title ILIKE $1 OR d.text ILIKE $1) AND d."deletedAt" IS NULL`
|
||||
: `WHERE d."deletedAt" IS NULL`;
|
||||
const queryParam = args.query ? [`%${sanitizeInput(args.query)}%`] : [];
|
||||
|
||||
// Collections facet
|
||||
const collections = await pgClient.query(`
|
||||
SELECT c.id, c.name, COUNT(d.id) as "documentCount"
|
||||
FROM collections c
|
||||
LEFT JOIN documents d ON d."collectionId" = c.id AND d."deletedAt" IS NULL
|
||||
WHERE c."deletedAt" IS NULL
|
||||
GROUP BY c.id, c.name
|
||||
ORDER BY "documentCount" DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
// Authors facet
|
||||
const authors = await pgClient.query(`
|
||||
SELECT u.id, u.name, COUNT(d.id) as "documentCount"
|
||||
FROM users u
|
||||
JOIN documents d ON d."createdById" = u.id
|
||||
${baseCondition}
|
||||
GROUP BY u.id, u.name
|
||||
ORDER BY "documentCount" DESC
|
||||
LIMIT 20
|
||||
`, queryParam);
|
||||
|
||||
// Date range stats
|
||||
const dateStats = await pgClient.query(`
|
||||
SELECT
|
||||
MIN(d."createdAt") as "oldestDocument",
|
||||
MAX(d."createdAt") as "newestDocument",
|
||||
COUNT(*) as "totalDocuments"
|
||||
FROM documents d
|
||||
${baseCondition}
|
||||
`, queryParam);
|
||||
|
||||
// Templates count
|
||||
const templates = await pgClient.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE template = true) as "templateCount",
|
||||
COUNT(*) FILTER (WHERE template = false) as "documentCount"
|
||||
FROM documents d
|
||||
${baseCondition}
|
||||
`, queryParam);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
collections: collections.rows,
|
||||
authors: authors.rows,
|
||||
dateRange: dateStats.rows[0],
|
||||
documentTypes: templates.rows[0],
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* search.recent - Get recently updated documents
|
||||
*/
|
||||
const searchRecent: BaseTool<PaginationArgs & { collection_id?: string; days?: number }> = {
|
||||
name: 'outline_search_recent',
|
||||
description: 'Get recently updated documents.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' },
|
||||
days: { type: 'number', description: 'Number of days to look back (default: 7)' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const days = args.days || 7;
|
||||
const conditions: string[] = [
|
||||
'd."deletedAt" IS NULL',
|
||||
'd."archivedAt" IS NULL',
|
||||
`d."updatedAt" >= NOW() - INTERVAL '${days} days'`
|
||||
];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.collection_id) {
|
||||
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
conditions.push(`d."collectionId" = $${idx++}`);
|
||||
params.push(args.collection_id);
|
||||
}
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
d.id, d.title, d.emoji, d."collectionId",
|
||||
d."updatedAt", d."createdAt",
|
||||
c.name as "collectionName",
|
||||
u.name as "lastModifiedByName"
|
||||
FROM documents d
|
||||
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||
LEFT JOIN users u ON d."lastModifiedById" = u.id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY d."updatedAt" DESC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
data: result.rows,
|
||||
days,
|
||||
pagination: { limit, offset, total: result.rows.length }
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* search.by_user_activity - Search by user activity
|
||||
*/
|
||||
const searchByUserActivity: BaseTool<PaginationArgs & { user_id: string; activity_type?: string }> = {
|
||||
name: 'outline_search_by_user_activity',
|
||||
description: 'Find documents a user has interacted with (created, edited, viewed, starred).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||
activity_type: { type: 'string', description: 'Activity type: created, edited, viewed, starred (default: all)' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
|
||||
const results: any = {};
|
||||
|
||||
if (!args.activity_type || args.activity_type === 'created') {
|
||||
const created = await pgClient.query(`
|
||||
SELECT d.id, d.title, d."createdAt" as "activityAt", 'created' as "activityType"
|
||||
FROM documents d
|
||||
WHERE d."createdById" = $1 AND d."deletedAt" IS NULL
|
||||
ORDER BY d."createdAt" DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, [args.user_id, limit, offset]);
|
||||
results.created = created.rows;
|
||||
}
|
||||
|
||||
if (!args.activity_type || args.activity_type === 'edited') {
|
||||
const edited = await pgClient.query(`
|
||||
SELECT d.id, d.title, d."updatedAt" as "activityAt", 'edited' as "activityType"
|
||||
FROM documents d
|
||||
WHERE d."lastModifiedById" = $1 AND d."deletedAt" IS NULL
|
||||
ORDER BY d."updatedAt" DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, [args.user_id, limit, offset]);
|
||||
results.edited = edited.rows;
|
||||
}
|
||||
|
||||
if (!args.activity_type || args.activity_type === 'viewed') {
|
||||
const viewed = await pgClient.query(`
|
||||
SELECT d.id, d.title, v."updatedAt" as "activityAt", 'viewed' as "activityType"
|
||||
FROM views v
|
||||
JOIN documents d ON v."documentId" = d.id
|
||||
WHERE v."userId" = $1 AND d."deletedAt" IS NULL
|
||||
ORDER BY v."updatedAt" DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, [args.user_id, limit, offset]);
|
||||
results.viewed = viewed.rows;
|
||||
}
|
||||
|
||||
if (!args.activity_type || args.activity_type === 'starred') {
|
||||
const starred = await pgClient.query(`
|
||||
SELECT d.id, d.title, s."createdAt" as "activityAt", 'starred' as "activityType"
|
||||
FROM stars s
|
||||
JOIN documents d ON s."documentId" = d.id
|
||||
WHERE s."userId" = $1 AND d."deletedAt" IS NULL
|
||||
ORDER BY s."createdAt" DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, [args.user_id, limit, offset]);
|
||||
results.starred = starred.rows;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: results, userId: args.user_id }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* search.orphaned_documents - Find orphaned documents
|
||||
*/
|
||||
const searchOrphanedDocuments: BaseTool<PaginationArgs> = {
|
||||
name: 'outline_search_orphaned_documents',
|
||||
description: 'Find documents without a collection or with deleted parent.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
|
||||
// Documents without collection or with deleted collection
|
||||
const orphaned = await pgClient.query(`
|
||||
SELECT
|
||||
d.id, d.title, d."collectionId", d."parentDocumentId",
|
||||
d."createdAt", d."updatedAt",
|
||||
CASE
|
||||
WHEN d."collectionId" IS NULL THEN 'no_collection'
|
||||
WHEN c."deletedAt" IS NOT NULL THEN 'deleted_collection'
|
||||
WHEN d."parentDocumentId" IS NOT NULL AND pd."deletedAt" IS NOT NULL THEN 'deleted_parent'
|
||||
ELSE 'orphaned'
|
||||
END as "orphanReason"
|
||||
FROM documents d
|
||||
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||
LEFT JOIN documents pd ON d."parentDocumentId" = pd.id
|
||||
WHERE d."deletedAt" IS NULL
|
||||
AND (
|
||||
d."collectionId" IS NULL
|
||||
OR c."deletedAt" IS NOT NULL
|
||||
OR (d."parentDocumentId" IS NOT NULL AND pd."deletedAt" IS NOT NULL)
|
||||
)
|
||||
ORDER BY d."updatedAt" DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, [limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
data: orphaned.rows,
|
||||
pagination: { limit, offset, total: orphaned.rows.length }
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* search.duplicates - Find potential duplicate documents
|
||||
*/
|
||||
const searchDuplicates: BaseTool<PaginationArgs & { similarity_threshold?: number }> = {
|
||||
name: 'outline_search_duplicates',
|
||||
description: 'Find documents with similar or identical titles.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
similarity_threshold: { type: 'number', description: 'Minimum similarity (0-1, default: 0.8)' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
|
||||
// Find exact title duplicates
|
||||
const exactDuplicates = await pgClient.query(`
|
||||
SELECT
|
||||
d1.id as "document1Id", d1.title as "document1Title",
|
||||
d2.id as "document2Id", d2.title as "document2Title",
|
||||
c1.name as "collection1Name", c2.name as "collection2Name"
|
||||
FROM documents d1
|
||||
JOIN documents d2 ON LOWER(d1.title) = LOWER(d2.title) AND d1.id < d2.id
|
||||
LEFT JOIN collections c1 ON d1."collectionId" = c1.id
|
||||
LEFT JOIN collections c2 ON d2."collectionId" = c2.id
|
||||
WHERE d1."deletedAt" IS NULL AND d2."deletedAt" IS NULL
|
||||
AND d1.template = false AND d2.template = false
|
||||
ORDER BY d1.title
|
||||
LIMIT $1 OFFSET $2
|
||||
`, [limit, offset]);
|
||||
|
||||
// Find documents with similar titles (starts with same prefix)
|
||||
const similarTitles = await pgClient.query(`
|
||||
SELECT
|
||||
d1.id as "document1Id", d1.title as "document1Title",
|
||||
d2.id as "document2Id", d2.title as "document2Title"
|
||||
FROM documents d1
|
||||
JOIN documents d2 ON
|
||||
LEFT(LOWER(d1.title), 20) = LEFT(LOWER(d2.title), 20)
|
||||
AND d1.id < d2.id
|
||||
AND d1.title != d2.title
|
||||
WHERE d1."deletedAt" IS NULL AND d2."deletedAt" IS NULL
|
||||
AND d1.template = false AND d2.template = false
|
||||
AND LENGTH(d1.title) > 10
|
||||
ORDER BY d1.title
|
||||
LIMIT $1 OFFSET $2
|
||||
`, [limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
exactDuplicates: exactDuplicates.rows,
|
||||
similarTitles: similarTitles.rows,
|
||||
pagination: { limit, offset }
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const advancedSearchTools: BaseTool<any>[] = [
|
||||
advancedSearchDocuments, getSearchFacets, searchRecent,
|
||||
searchByUserActivity, searchOrphanedDocuments, searchDuplicates
|
||||
];
|
||||
452
src/tools/analytics.ts
Normal file
452
src/tools/analytics.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Analytics Tools
|
||||
* Usage statistics, reports, insights
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse } from '../types/tools.js';
|
||||
import { isValidUUID } from '../utils/security.js';
|
||||
|
||||
interface DateRangeArgs {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* analytics.overview - Get overall workspace analytics
|
||||
*/
|
||||
const getAnalyticsOverview: BaseTool<DateRangeArgs> = {
|
||||
name: 'outline_analytics_overview',
|
||||
description: 'Get overall workspace analytics including document counts, user activity, etc.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date_from: { type: 'string', description: 'Start date (ISO format)' },
|
||||
date_to: { type: 'string', description: 'End date (ISO format)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const dateCondition = args.date_from && args.date_to
|
||||
? `AND "createdAt" BETWEEN '${args.date_from}' AND '${args.date_to}'`
|
||||
: '';
|
||||
|
||||
// Document stats
|
||||
const docStats = await pgClient.query(`
|
||||
SELECT
|
||||
COUNT(*) as "totalDocuments",
|
||||
COUNT(*) FILTER (WHERE template = true) as "templates",
|
||||
COUNT(*) FILTER (WHERE "archivedAt" IS NOT NULL) as "archived",
|
||||
COUNT(*) FILTER (WHERE "publishedAt" IS NOT NULL) as "published",
|
||||
COUNT(*) FILTER (WHERE "deletedAt" IS NOT NULL) as "deleted"
|
||||
FROM documents
|
||||
`);
|
||||
|
||||
// Collection stats
|
||||
const collStats = await pgClient.query(`
|
||||
SELECT
|
||||
COUNT(*) as "totalCollections",
|
||||
COUNT(*) FILTER (WHERE "deletedAt" IS NULL) as "active"
|
||||
FROM collections
|
||||
`);
|
||||
|
||||
// User stats
|
||||
const userStats = await pgClient.query(`
|
||||
SELECT
|
||||
COUNT(*) as "totalUsers",
|
||||
COUNT(*) FILTER (WHERE "suspendedAt" IS NULL AND "deletedAt" IS NULL) as "active",
|
||||
COUNT(*) FILTER (WHERE role = 'admin') as "admins"
|
||||
FROM users
|
||||
`);
|
||||
|
||||
// Recent activity
|
||||
const recentActivity = await pgClient.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE "createdAt" >= NOW() - INTERVAL '24 hours') as "documentsLast24h",
|
||||
COUNT(*) FILTER (WHERE "createdAt" >= NOW() - INTERVAL '7 days') as "documentsLast7d",
|
||||
COUNT(*) FILTER (WHERE "createdAt" >= NOW() - INTERVAL '30 days') as "documentsLast30d"
|
||||
FROM documents
|
||||
WHERE "deletedAt" IS NULL
|
||||
`);
|
||||
|
||||
// View stats
|
||||
const viewStats = await pgClient.query(`
|
||||
SELECT
|
||||
COUNT(*) as "totalViews",
|
||||
COUNT(DISTINCT "userId") as "uniqueViewers",
|
||||
COUNT(DISTINCT "documentId") as "viewedDocuments"
|
||||
FROM views
|
||||
`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
documents: docStats.rows[0],
|
||||
collections: collStats.rows[0],
|
||||
users: userStats.rows[0],
|
||||
recentActivity: recentActivity.rows[0],
|
||||
views: viewStats.rows[0],
|
||||
generatedAt: new Date().toISOString(),
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* analytics.user_activity - Get user activity analytics
|
||||
*/
|
||||
const getUserActivityAnalytics: BaseTool<{ user_id?: string; days?: number }> = {
|
||||
name: 'outline_analytics_user_activity',
|
||||
description: 'Get detailed user activity analytics.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'Specific user ID (UUID), or all users if omitted' },
|
||||
days: { type: 'number', description: 'Number of days to analyze (default: 30)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const days = args.days || 30;
|
||||
const userCondition = args.user_id ? `AND u.id = '${args.user_id}'` : '';
|
||||
|
||||
if (args.user_id && !isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
|
||||
// Most active users
|
||||
const activeUsers = await pgClient.query(`
|
||||
SELECT
|
||||
u.id, u.name, u.email,
|
||||
COUNT(DISTINCT d.id) FILTER (WHERE d."createdAt" >= NOW() - INTERVAL '${days} days') as "documentsCreated",
|
||||
COUNT(DISTINCT d2.id) FILTER (WHERE d2."updatedAt" >= NOW() - INTERVAL '${days} days') as "documentsEdited",
|
||||
COUNT(DISTINCT v."documentId") FILTER (WHERE v."createdAt" >= NOW() - INTERVAL '${days} days') as "documentsViewed",
|
||||
COUNT(DISTINCT c.id) FILTER (WHERE c."createdAt" >= NOW() - INTERVAL '${days} days') as "commentsAdded"
|
||||
FROM users u
|
||||
LEFT JOIN documents d ON d."createdById" = u.id
|
||||
LEFT JOIN documents d2 ON d2."lastModifiedById" = u.id
|
||||
LEFT JOIN views v ON v."userId" = u.id
|
||||
LEFT JOIN comments c ON c."createdById" = u.id
|
||||
WHERE u."deletedAt" IS NULL ${userCondition}
|
||||
GROUP BY u.id, u.name, u.email
|
||||
ORDER BY "documentsCreated" DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
// Activity by day of week
|
||||
const activityByDay = await pgClient.query(`
|
||||
SELECT
|
||||
EXTRACT(DOW FROM d."createdAt") as "dayOfWeek",
|
||||
COUNT(*) as "documentsCreated"
|
||||
FROM documents d
|
||||
WHERE d."createdAt" >= NOW() - INTERVAL '${days} days'
|
||||
AND d."deletedAt" IS NULL
|
||||
GROUP BY EXTRACT(DOW FROM d."createdAt")
|
||||
ORDER BY "dayOfWeek"
|
||||
`);
|
||||
|
||||
// Activity by hour
|
||||
const activityByHour = await pgClient.query(`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM d."createdAt") as "hour",
|
||||
COUNT(*) as "documentsCreated"
|
||||
FROM documents d
|
||||
WHERE d."createdAt" >= NOW() - INTERVAL '${days} days'
|
||||
AND d."deletedAt" IS NULL
|
||||
GROUP BY EXTRACT(HOUR FROM d."createdAt")
|
||||
ORDER BY "hour"
|
||||
`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
activeUsers: activeUsers.rows,
|
||||
activityByDayOfWeek: activityByDay.rows,
|
||||
activityByHour: activityByHour.rows,
|
||||
periodDays: days,
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* analytics.content_insights - Get content insights
|
||||
*/
|
||||
const getContentInsights: BaseTool<{ collection_id?: string }> = {
|
||||
name: 'outline_analytics_content_insights',
|
||||
description: 'Get insights about content: popular documents, stale content, etc.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const collectionCondition = args.collection_id
|
||||
? `AND d."collectionId" = '${args.collection_id}'`
|
||||
: '';
|
||||
|
||||
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
|
||||
// Most viewed documents
|
||||
const mostViewed = await pgClient.query(`
|
||||
SELECT
|
||||
d.id, d.title, d.emoji, c.name as "collectionName",
|
||||
COUNT(v.id) as "viewCount",
|
||||
COUNT(DISTINCT v."userId") as "uniqueViewers"
|
||||
FROM documents d
|
||||
LEFT JOIN views v ON v."documentId" = d.id
|
||||
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||
WHERE d."deletedAt" IS NULL ${collectionCondition}
|
||||
GROUP BY d.id, d.title, d.emoji, c.name
|
||||
ORDER BY "viewCount" DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
// Most starred documents
|
||||
const mostStarred = await pgClient.query(`
|
||||
SELECT
|
||||
d.id, d.title, d.emoji, c.name as "collectionName",
|
||||
COUNT(s.id) as "starCount"
|
||||
FROM documents d
|
||||
LEFT JOIN stars s ON s."documentId" = d.id
|
||||
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||
WHERE d."deletedAt" IS NULL ${collectionCondition}
|
||||
GROUP BY d.id, d.title, d.emoji, c.name
|
||||
HAVING COUNT(s.id) > 0
|
||||
ORDER BY "starCount" DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
// Stale documents (not updated in 90 days)
|
||||
const staleDocuments = await pgClient.query(`
|
||||
SELECT
|
||||
d.id, d.title, d.emoji, c.name as "collectionName",
|
||||
d."updatedAt",
|
||||
EXTRACT(DAY FROM NOW() - d."updatedAt") as "daysSinceUpdate"
|
||||
FROM documents d
|
||||
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||
WHERE d."deletedAt" IS NULL
|
||||
AND d."archivedAt" IS NULL
|
||||
AND d.template = false
|
||||
AND d."updatedAt" < NOW() - INTERVAL '90 days'
|
||||
${collectionCondition}
|
||||
ORDER BY d."updatedAt" ASC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
// Documents without views
|
||||
const neverViewed = await pgClient.query(`
|
||||
SELECT
|
||||
d.id, d.title, d.emoji, c.name as "collectionName",
|
||||
d."createdAt"
|
||||
FROM documents d
|
||||
LEFT JOIN views v ON v."documentId" = d.id
|
||||
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||
WHERE d."deletedAt" IS NULL
|
||||
AND d.template = false
|
||||
AND v.id IS NULL
|
||||
${collectionCondition}
|
||||
ORDER BY d."createdAt" DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
mostViewed: mostViewed.rows,
|
||||
mostStarred: mostStarred.rows,
|
||||
staleDocuments: staleDocuments.rows,
|
||||
neverViewed: neverViewed.rows,
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* analytics.collection_stats - Get collection statistics
|
||||
*/
|
||||
const getCollectionStats: BaseTool<{ collection_id?: string }> = {
|
||||
name: 'outline_analytics_collection_stats',
|
||||
description: 'Get detailed statistics for collections.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
collection_id: { type: 'string', description: 'Specific collection ID (UUID), or all collections if omitted' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const collectionCondition = args.collection_id
|
||||
? `AND c.id = '${args.collection_id}'`
|
||||
: '';
|
||||
|
||||
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
|
||||
const stats = await pgClient.query(`
|
||||
SELECT
|
||||
c.id, c.name, c.icon, c.color,
|
||||
COUNT(DISTINCT d.id) as "documentCount",
|
||||
COUNT(DISTINCT d.id) FILTER (WHERE d.template = true) as "templateCount",
|
||||
COUNT(DISTINCT d.id) FILTER (WHERE d."archivedAt" IS NOT NULL) as "archivedCount",
|
||||
COUNT(DISTINCT cu."userId") as "memberCount",
|
||||
COUNT(DISTINCT cg."groupId") as "groupCount",
|
||||
MAX(d."updatedAt") as "lastDocumentUpdate",
|
||||
AVG(LENGTH(d.text)) as "avgDocumentLength"
|
||||
FROM collections c
|
||||
LEFT JOIN documents d ON d."collectionId" = c.id AND d."deletedAt" IS NULL
|
||||
LEFT JOIN collection_users cu ON cu."collectionId" = c.id
|
||||
LEFT JOIN collection_group_memberships cg ON cg."collectionId" = c.id
|
||||
WHERE c."deletedAt" IS NULL ${collectionCondition}
|
||||
GROUP BY c.id, c.name, c.icon, c.color
|
||||
ORDER BY "documentCount" DESC
|
||||
`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: stats.rows }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* analytics.growth_metrics - Get growth metrics over time
|
||||
*/
|
||||
const getGrowthMetrics: BaseTool<{ period?: string }> = {
|
||||
name: 'outline_analytics_growth_metrics',
|
||||
description: 'Get growth metrics: documents, users, activity over time.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
period: { type: 'string', description: 'Period: week, month, quarter, year (default: month)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const period = args.period || 'month';
|
||||
const intervals: Record<string, string> = {
|
||||
week: '7 days',
|
||||
month: '30 days',
|
||||
quarter: '90 days',
|
||||
year: '365 days',
|
||||
};
|
||||
const interval = intervals[period] || '30 days';
|
||||
|
||||
// Document growth by day
|
||||
const documentGrowth = await pgClient.query(`
|
||||
SELECT
|
||||
DATE(d."createdAt") as date,
|
||||
COUNT(*) as "newDocuments",
|
||||
SUM(COUNT(*)) OVER (ORDER BY DATE(d."createdAt")) as "cumulativeDocuments"
|
||||
FROM documents d
|
||||
WHERE d."createdAt" >= NOW() - INTERVAL '${interval}'
|
||||
AND d."deletedAt" IS NULL
|
||||
GROUP BY DATE(d."createdAt")
|
||||
ORDER BY date
|
||||
`);
|
||||
|
||||
// User growth
|
||||
const userGrowth = await pgClient.query(`
|
||||
SELECT
|
||||
DATE(u."createdAt") as date,
|
||||
COUNT(*) as "newUsers",
|
||||
SUM(COUNT(*)) OVER (ORDER BY DATE(u."createdAt")) as "cumulativeUsers"
|
||||
FROM users u
|
||||
WHERE u."createdAt" >= NOW() - INTERVAL '${interval}'
|
||||
AND u."deletedAt" IS NULL
|
||||
GROUP BY DATE(u."createdAt")
|
||||
ORDER BY date
|
||||
`);
|
||||
|
||||
// Collection growth
|
||||
const collectionGrowth = await pgClient.query(`
|
||||
SELECT
|
||||
DATE(c."createdAt") as date,
|
||||
COUNT(*) as "newCollections"
|
||||
FROM collections c
|
||||
WHERE c."createdAt" >= NOW() - INTERVAL '${interval}'
|
||||
AND c."deletedAt" IS NULL
|
||||
GROUP BY DATE(c."createdAt")
|
||||
ORDER BY date
|
||||
`);
|
||||
|
||||
// Period comparison
|
||||
const comparison = await pgClient.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM documents WHERE "createdAt" >= NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "currentPeriodDocs",
|
||||
(SELECT COUNT(*) FROM documents WHERE "createdAt" >= NOW() - INTERVAL '${interval}' * 2 AND "createdAt" < NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "previousPeriodDocs",
|
||||
(SELECT COUNT(*) FROM users WHERE "createdAt" >= NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "currentPeriodUsers",
|
||||
(SELECT COUNT(*) FROM users WHERE "createdAt" >= NOW() - INTERVAL '${interval}' * 2 AND "createdAt" < NOW() - INTERVAL '${interval}' AND "deletedAt" IS NULL) as "previousPeriodUsers"
|
||||
`);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
documentGrowth: documentGrowth.rows,
|
||||
userGrowth: userGrowth.rows,
|
||||
collectionGrowth: collectionGrowth.rows,
|
||||
periodComparison: comparison.rows[0],
|
||||
period,
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* analytics.search_analytics - Get search analytics
|
||||
*/
|
||||
const getSearchAnalytics: BaseTool<{ days?: number }> = {
|
||||
name: 'outline_analytics_search',
|
||||
description: 'Get search analytics: popular queries, search patterns.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
days: { type: 'number', description: 'Number of days to analyze (default: 30)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const days = args.days || 30;
|
||||
|
||||
// Popular search queries
|
||||
const popularQueries = await pgClient.query(`
|
||||
SELECT
|
||||
query,
|
||||
COUNT(*) as "searchCount",
|
||||
COUNT(DISTINCT "userId") as "uniqueSearchers"
|
||||
FROM search_queries
|
||||
WHERE "createdAt" >= NOW() - INTERVAL '${days} days'
|
||||
GROUP BY query
|
||||
ORDER BY "searchCount" DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
|
||||
// Search volume by day
|
||||
const searchVolume = await pgClient.query(`
|
||||
SELECT
|
||||
DATE("createdAt") as date,
|
||||
COUNT(*) as "searches",
|
||||
COUNT(DISTINCT "userId") as "uniqueSearchers"
|
||||
FROM search_queries
|
||||
WHERE "createdAt" >= NOW() - INTERVAL '${days} days'
|
||||
GROUP BY DATE("createdAt")
|
||||
ORDER BY date
|
||||
`);
|
||||
|
||||
// Zero result queries (if results column exists)
|
||||
const zeroResults = await pgClient.query(`
|
||||
SELECT
|
||||
query,
|
||||
COUNT(*) as "searchCount"
|
||||
FROM search_queries
|
||||
WHERE "createdAt" >= NOW() - INTERVAL '${days} days'
|
||||
AND results = 0
|
||||
GROUP BY query
|
||||
ORDER BY "searchCount" DESC
|
||||
LIMIT 10
|
||||
`).catch(() => ({ rows: [] })); // Handle if results column doesn't exist
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
popularQueries: popularQueries.rows,
|
||||
searchVolume: searchVolume.rows,
|
||||
zeroResultQueries: zeroResults.rows,
|
||||
periodDays: days,
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const analyticsTools: BaseTool<any>[] = [
|
||||
getAnalyticsOverview, getUserActivityAnalytics, getContentInsights,
|
||||
getCollectionStats, getGrowthMetrics, getSearchAnalytics
|
||||
];
|
||||
287
src/tools/bulk-operations.ts
Normal file
287
src/tools/bulk-operations.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Bulk Operations Tools
|
||||
* Batch operations on documents, collections, etc.
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse } from '../types/tools.js';
|
||||
import { isValidUUID } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* bulk.archive_documents - Archive multiple documents
|
||||
*/
|
||||
const bulkArchiveDocuments: BaseTool<{ document_ids: string[] }> = {
|
||||
name: 'outline_bulk_archive_documents',
|
||||
description: 'Archive multiple documents at once.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
|
||||
},
|
||||
required: ['document_ids'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
|
||||
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
|
||||
|
||||
// Validate all IDs
|
||||
for (const id of args.document_ids) {
|
||||
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
|
||||
}
|
||||
|
||||
const result = await pgClient.query(`
|
||||
UPDATE documents
|
||||
SET "archivedAt" = NOW(), "updatedAt" = NOW()
|
||||
WHERE id = ANY($1) AND "archivedAt" IS NULL AND "deletedAt" IS NULL
|
||||
RETURNING id, title
|
||||
`, [args.document_ids]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
archived: result.rows,
|
||||
archivedCount: result.rows.length,
|
||||
requestedCount: args.document_ids.length,
|
||||
message: `${result.rows.length} documents archived`
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* bulk.delete_documents - Soft delete multiple documents
|
||||
*/
|
||||
const bulkDeleteDocuments: BaseTool<{ document_ids: string[] }> = {
|
||||
name: 'outline_bulk_delete_documents',
|
||||
description: 'Soft delete multiple documents at once.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
|
||||
},
|
||||
required: ['document_ids'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
|
||||
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
|
||||
|
||||
for (const id of args.document_ids) {
|
||||
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
|
||||
}
|
||||
|
||||
const deletedById = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||
const userId = deletedById.rows.length > 0 ? deletedById.rows[0].id : null;
|
||||
|
||||
const result = await pgClient.query(`
|
||||
UPDATE documents
|
||||
SET "deletedAt" = NOW(), "deletedById" = $2, "updatedAt" = NOW()
|
||||
WHERE id = ANY($1) AND "deletedAt" IS NULL
|
||||
RETURNING id, title
|
||||
`, [args.document_ids, userId]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
deleted: result.rows,
|
||||
deletedCount: result.rows.length,
|
||||
requestedCount: args.document_ids.length,
|
||||
message: `${result.rows.length} documents deleted`
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* bulk.move_documents - Move multiple documents to collection
|
||||
*/
|
||||
const bulkMoveDocuments: BaseTool<{ document_ids: string[]; collection_id: string; parent_document_id?: string }> = {
|
||||
name: 'outline_bulk_move_documents',
|
||||
description: 'Move multiple documents to a collection or under a parent document.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
|
||||
collection_id: { type: 'string', description: 'Target collection ID (UUID)' },
|
||||
parent_document_id: { type: 'string', description: 'Optional parent document ID (UUID)' },
|
||||
},
|
||||
required: ['document_ids', 'collection_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
|
||||
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
|
||||
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
if (args.parent_document_id && !isValidUUID(args.parent_document_id)) throw new Error('Invalid parent_document_id');
|
||||
|
||||
for (const id of args.document_ids) {
|
||||
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
|
||||
}
|
||||
|
||||
// Verify collection exists
|
||||
const collectionCheck = await pgClient.query(
|
||||
`SELECT id FROM collections WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||
[args.collection_id]
|
||||
);
|
||||
if (collectionCheck.rows.length === 0) throw new Error('Collection not found');
|
||||
|
||||
const result = await pgClient.query(`
|
||||
UPDATE documents
|
||||
SET "collectionId" = $2, "parentDocumentId" = $3, "updatedAt" = NOW()
|
||||
WHERE id = ANY($1) AND "deletedAt" IS NULL
|
||||
RETURNING id, title, "collectionId"
|
||||
`, [args.document_ids, args.collection_id, args.parent_document_id || null]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
moved: result.rows,
|
||||
movedCount: result.rows.length,
|
||||
targetCollectionId: args.collection_id,
|
||||
message: `${result.rows.length} documents moved`
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* bulk.restore_documents - Restore multiple deleted documents
|
||||
*/
|
||||
const bulkRestoreDocuments: BaseTool<{ document_ids: string[] }> = {
|
||||
name: 'outline_bulk_restore_documents',
|
||||
description: 'Restore multiple soft-deleted documents.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_ids: { type: 'array', items: { type: 'string' }, description: 'Array of document IDs (UUIDs)' },
|
||||
},
|
||||
required: ['document_ids'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!args.document_ids || args.document_ids.length === 0) throw new Error('At least one document_id required');
|
||||
if (args.document_ids.length > 100) throw new Error('Maximum 100 documents per operation');
|
||||
|
||||
for (const id of args.document_ids) {
|
||||
if (!isValidUUID(id)) throw new Error(`Invalid document ID: ${id}`);
|
||||
}
|
||||
|
||||
const result = await pgClient.query(`
|
||||
UPDATE documents
|
||||
SET "deletedAt" = NULL, "deletedById" = NULL, "updatedAt" = NOW()
|
||||
WHERE id = ANY($1) AND "deletedAt" IS NOT NULL
|
||||
RETURNING id, title
|
||||
`, [args.document_ids]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
restored: result.rows,
|
||||
restoredCount: result.rows.length,
|
||||
requestedCount: args.document_ids.length,
|
||||
message: `${result.rows.length} documents restored`
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* bulk.add_users_to_collection - Add multiple users to collection
|
||||
*/
|
||||
const bulkAddUsersToCollection: BaseTool<{ user_ids: string[]; collection_id: string; permission?: string }> = {
|
||||
name: 'outline_bulk_add_users_to_collection',
|
||||
description: 'Add multiple users to a collection with specified permission.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_ids: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs (UUIDs)' },
|
||||
collection_id: { type: 'string', description: 'Collection ID (UUID)' },
|
||||
permission: { type: 'string', description: 'Permission level: read_write, read, admin (default: read_write)' },
|
||||
},
|
||||
required: ['user_ids', 'collection_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!args.user_ids || args.user_ids.length === 0) throw new Error('At least one user_id required');
|
||||
if (args.user_ids.length > 50) throw new Error('Maximum 50 users per operation');
|
||||
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
|
||||
for (const id of args.user_ids) {
|
||||
if (!isValidUUID(id)) throw new Error(`Invalid user ID: ${id}`);
|
||||
}
|
||||
|
||||
const permission = args.permission || 'read_write';
|
||||
const creatorResult = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_ids[0];
|
||||
|
||||
const added: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
|
||||
for (const userId of args.user_ids) {
|
||||
// Check if already exists
|
||||
const existing = await pgClient.query(
|
||||
`SELECT "userId" FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2`,
|
||||
[userId, args.collection_id]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
skipped.push(userId);
|
||||
} else {
|
||||
await pgClient.query(`
|
||||
INSERT INTO collection_users ("userId", "collectionId", permission, "createdById", "createdAt", "updatedAt")
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
`, [userId, args.collection_id, permission, createdById]);
|
||||
added.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
addedUserIds: added,
|
||||
skippedUserIds: skipped,
|
||||
addedCount: added.length,
|
||||
skippedCount: skipped.length,
|
||||
permission,
|
||||
message: `${added.length} users added, ${skipped.length} already existed`
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* bulk.remove_users_from_collection - Remove multiple users from collection
|
||||
*/
|
||||
const bulkRemoveUsersFromCollection: BaseTool<{ user_ids: string[]; collection_id: string }> = {
|
||||
name: 'outline_bulk_remove_users_from_collection',
|
||||
description: 'Remove multiple users from a collection.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_ids: { type: 'array', items: { type: 'string' }, description: 'Array of user IDs (UUIDs)' },
|
||||
collection_id: { type: 'string', description: 'Collection ID (UUID)' },
|
||||
},
|
||||
required: ['user_ids', 'collection_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!args.user_ids || args.user_ids.length === 0) throw new Error('At least one user_id required');
|
||||
if (args.user_ids.length > 50) throw new Error('Maximum 50 users per operation');
|
||||
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
|
||||
for (const id of args.user_ids) {
|
||||
if (!isValidUUID(id)) throw new Error(`Invalid user ID: ${id}`);
|
||||
}
|
||||
|
||||
const result = await pgClient.query(`
|
||||
DELETE FROM collection_users
|
||||
WHERE "userId" = ANY($1) AND "collectionId" = $2
|
||||
RETURNING "userId"
|
||||
`, [args.user_ids, args.collection_id]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
removedUserIds: result.rows.map(r => r.userId),
|
||||
removedCount: result.rows.length,
|
||||
requestedCount: args.user_ids.length,
|
||||
message: `${result.rows.length} users removed from collection`
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const bulkOperationsTools: BaseTool<any>[] = [
|
||||
bulkArchiveDocuments, bulkDeleteDocuments, bulkMoveDocuments,
|
||||
bulkRestoreDocuments, bulkAddUsersToCollection, bulkRemoveUsersFromCollection
|
||||
];
|
||||
136
src/tools/emojis.ts
Normal file
136
src/tools/emojis.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Emojis Tools
|
||||
* Custom emoji management
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js';
|
||||
|
||||
interface EmojiListArgs extends PaginationArgs {
|
||||
team_id?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* emojis.list - List custom emojis
|
||||
*/
|
||||
const listEmojis: BaseTool<EmojiListArgs> = {
|
||||
name: 'outline_list_emojis',
|
||||
description: 'List custom emojis available in the workspace.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
team_id: { type: 'string', description: 'Filter by team ID (UUID)' },
|
||||
search: { type: 'string', description: 'Search emojis by name' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.team_id) {
|
||||
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id');
|
||||
conditions.push(`e."teamId" = $${idx++}`);
|
||||
params.push(args.team_id);
|
||||
}
|
||||
if (args.search) {
|
||||
conditions.push(`e.name ILIKE $${idx++}`);
|
||||
params.push(`%${sanitizeInput(args.search)}%`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
e.id, e.name, e.url, e."teamId", e."createdById",
|
||||
e."createdAt", e."updatedAt",
|
||||
u.name as "createdByName"
|
||||
FROM emojis e
|
||||
LEFT JOIN users u ON e."createdById" = u.id
|
||||
${whereClause}
|
||||
ORDER BY e.name ASC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* emojis.create - Create custom emoji
|
||||
*/
|
||||
const createEmoji: BaseTool<{ name: string; url: string }> = {
|
||||
name: 'outline_create_emoji',
|
||||
description: 'Create a new custom emoji.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Emoji name (without colons)' },
|
||||
url: { type: 'string', description: 'URL to emoji image' },
|
||||
},
|
||||
required: ['name', 'url'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const teamResult = await pgClient.query(`SELECT id FROM teams WHERE "deletedAt" IS NULL LIMIT 1`);
|
||||
if (teamResult.rows.length === 0) throw new Error('No team found');
|
||||
|
||||
const userResult = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||
if (userResult.rows.length === 0) throw new Error('No admin user found');
|
||||
|
||||
// Check if emoji name already exists
|
||||
const existing = await pgClient.query(
|
||||
`SELECT id FROM emojis WHERE name = $1 AND "teamId" = $2`,
|
||||
[sanitizeInput(args.name), teamResult.rows[0].id]
|
||||
);
|
||||
if (existing.rows.length > 0) throw new Error('Emoji with this name already exists');
|
||||
|
||||
const result = await pgClient.query(`
|
||||
INSERT INTO emojis (id, name, url, "teamId", "createdById", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING *
|
||||
`, [sanitizeInput(args.name), args.url, teamResult.rows[0].id, userResult.rows[0].id]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Emoji created' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* emojis.delete - Delete custom emoji
|
||||
*/
|
||||
const deleteEmoji: BaseTool<{ id: string }> = {
|
||||
name: 'outline_delete_emoji',
|
||||
description: 'Delete a custom emoji.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Emoji ID (UUID)' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid emoji ID');
|
||||
|
||||
const result = await pgClient.query(
|
||||
`DELETE FROM emojis WHERE id = $1 RETURNING id, name`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Emoji not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Emoji deleted' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const emojisTools: BaseTool<any>[] = [listEmojis, createEmoji, deleteEmoji];
|
||||
179
src/tools/imports-tools.ts
Normal file
179
src/tools/imports-tools.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Imports Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||
|
||||
interface ImportListArgs extends PaginationArgs {
|
||||
team_id?: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* imports.list - List imports
|
||||
*/
|
||||
const listImports: BaseTool<ImportListArgs> = {
|
||||
name: 'outline_list_imports',
|
||||
description: 'List document import jobs.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
team_id: { type: 'string', description: 'Filter by team ID (UUID)' },
|
||||
state: { type: 'string', description: 'Filter by state (pending, processing, completed, failed)' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.team_id) {
|
||||
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id');
|
||||
conditions.push(`i."teamId" = $${idx++}`);
|
||||
params.push(args.team_id);
|
||||
}
|
||||
if (args.state) {
|
||||
conditions.push(`i.state = $${idx++}`);
|
||||
params.push(args.state);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
i.id, i.state, i.type, i."documentCount", i."fileCount",
|
||||
i."teamId", i."createdById", i."integrationId",
|
||||
i."createdAt", i."updatedAt",
|
||||
u.name as "createdByName",
|
||||
t.name as "teamName"
|
||||
FROM imports i
|
||||
LEFT JOIN users u ON i."createdById" = u.id
|
||||
LEFT JOIN teams t ON i."teamId" = t.id
|
||||
${whereClause}
|
||||
ORDER BY i."createdAt" DESC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* imports.status - Get import status
|
||||
*/
|
||||
const getImportStatus: BaseTool<{ id: string }> = {
|
||||
name: 'outline_get_import_status',
|
||||
description: 'Get detailed status of an import job.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Import ID (UUID)' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid import ID');
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT i.*, u.name as "createdByName"
|
||||
FROM imports i
|
||||
LEFT JOIN users u ON i."createdById" = u.id
|
||||
WHERE i.id = $1
|
||||
`, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Import not found');
|
||||
|
||||
// Get import tasks
|
||||
const tasks = await pgClient.query(`
|
||||
SELECT id, state, "documentId", "createdAt"
|
||||
FROM import_tasks
|
||||
WHERE "importId" = $1
|
||||
ORDER BY "createdAt" DESC
|
||||
LIMIT 50
|
||||
`, [args.id]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
import: result.rows[0],
|
||||
tasks: tasks.rows,
|
||||
taskCount: tasks.rows.length,
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* imports.create - Create import job
|
||||
*/
|
||||
const createImport: BaseTool<{ type: string; collection_id?: string }> = {
|
||||
name: 'outline_create_import',
|
||||
description: 'Create a new import job. Note: This creates the job record, actual file upload handled separately.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', description: 'Import type (e.g., "notion", "confluence", "markdown")' },
|
||||
collection_id: { type: 'string', description: 'Target collection ID (UUID, optional)' },
|
||||
},
|
||||
required: ['type'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
|
||||
const team = await pgClient.query(`SELECT id FROM teams WHERE "deletedAt" IS NULL LIMIT 1`);
|
||||
if (team.rows.length === 0) throw new Error('No team found');
|
||||
|
||||
const user = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||
if (user.rows.length === 0) throw new Error('No admin user found');
|
||||
|
||||
const result = await pgClient.query(`
|
||||
INSERT INTO imports (id, type, state, "teamId", "createdById", "documentCount", "fileCount", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, 'pending', $2, $3, 0, 0, NOW(), NOW())
|
||||
RETURNING *
|
||||
`, [args.type, team.rows[0].id, user.rows[0].id]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Import job created' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* imports.cancel - Cancel import job
|
||||
*/
|
||||
const cancelImport: BaseTool<{ id: string }> = {
|
||||
name: 'outline_cancel_import',
|
||||
description: 'Cancel a pending or processing import job.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Import ID (UUID)' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid import ID');
|
||||
|
||||
const result = await pgClient.query(`
|
||||
UPDATE imports
|
||||
SET state = 'cancelled', "updatedAt" = NOW()
|
||||
WHERE id = $1 AND state IN ('pending', 'processing')
|
||||
RETURNING id, state, type
|
||||
`, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Import not found or cannot be cancelled');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Import cancelled' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const importsTools: BaseTool<any>[] = [listImports, getImportStatus, createImport, cancelImport];
|
||||
@@ -63,3 +63,36 @@ export { backlinksTools } from './backlinks.js';
|
||||
|
||||
// Search Queries Tools - Search analytics
|
||||
export { searchQueriesTools } from './search-queries.js';
|
||||
|
||||
// Teams Tools - Team/workspace management
|
||||
export { teamsTools } from './teams.js';
|
||||
|
||||
// Integrations Tools - External integrations (Slack, embeds, etc.)
|
||||
export { integrationsTools } from './integrations.js';
|
||||
|
||||
// Notifications Tools - User notifications
|
||||
export { notificationsTools } from './notifications.js';
|
||||
|
||||
// Subscriptions Tools - Document subscriptions
|
||||
export { subscriptionsTools } from './subscriptions.js';
|
||||
|
||||
// Templates Tools - Document templates
|
||||
export { templatesTools } from './templates.js';
|
||||
|
||||
// Imports Tools - Import job management
|
||||
export { importsTools } from './imports-tools.js';
|
||||
|
||||
// Emojis Tools - Custom emoji management
|
||||
export { emojisTools } from './emojis.js';
|
||||
|
||||
// User Permissions Tools - Permission management
|
||||
export { userPermissionsTools } from './user-permissions.js';
|
||||
|
||||
// Bulk Operations Tools - Batch operations
|
||||
export { bulkOperationsTools } from './bulk-operations.js';
|
||||
|
||||
// Advanced Search Tools - Full-text search and facets
|
||||
export { advancedSearchTools } from './advanced-search.js';
|
||||
|
||||
// Analytics Tools - Usage statistics and insights
|
||||
export { analyticsTools } from './analytics.js';
|
||||
|
||||
275
src/tools/integrations.ts
Normal file
275
src/tools/integrations.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Integrations Tools
|
||||
* CRÍTICO para embeds e integrações externas
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js';
|
||||
|
||||
interface IntegrationListArgs extends PaginationArgs {
|
||||
team_id?: string;
|
||||
service?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface IntegrationCreateArgs {
|
||||
service: string;
|
||||
type?: string;
|
||||
collection_id?: string;
|
||||
events?: string[];
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface IntegrationUpdateArgs {
|
||||
id: string;
|
||||
events?: string[];
|
||||
settings?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* integrations.list - List integrations
|
||||
*/
|
||||
const listIntegrations: BaseTool<IntegrationListArgs> = {
|
||||
name: 'outline_list_integrations',
|
||||
description: 'List configured integrations (Slack, embed sources, etc.).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
team_id: { type: 'string', description: 'Filter by team ID (UUID)' },
|
||||
service: { type: 'string', description: 'Filter by service (e.g., "slack", "github")' },
|
||||
type: { type: 'string', description: 'Filter by type (e.g., "embed", "linkedAccount")' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = ['i."deletedAt" IS NULL'];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.team_id) {
|
||||
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id');
|
||||
conditions.push(`i."teamId" = $${idx++}`);
|
||||
params.push(args.team_id);
|
||||
}
|
||||
if (args.service) {
|
||||
conditions.push(`i.service = $${idx++}`);
|
||||
params.push(sanitizeInput(args.service));
|
||||
}
|
||||
if (args.type) {
|
||||
conditions.push(`i.type = $${idx++}`);
|
||||
params.push(sanitizeInput(args.type));
|
||||
}
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
i.id, i.service, i.type, i.events, i.settings,
|
||||
i."teamId", i."userId", i."collectionId", i."authenticationId",
|
||||
i."createdAt", i."updatedAt",
|
||||
t.name as "teamName",
|
||||
u.name as "userName",
|
||||
c.name as "collectionName"
|
||||
FROM integrations i
|
||||
LEFT JOIN teams t ON i."teamId" = t.id
|
||||
LEFT JOIN users u ON i."userId" = u.id
|
||||
LEFT JOIN collections c ON i."collectionId" = c.id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY i."createdAt" DESC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* integrations.info - Get integration details
|
||||
*/
|
||||
const getIntegration: BaseTool<{ id: string }> = {
|
||||
name: 'outline_get_integration',
|
||||
description: 'Get detailed information about a specific integration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Integration ID (UUID)' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid integration ID');
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
i.*, t.name as "teamName", u.name as "userName", c.name as "collectionName"
|
||||
FROM integrations i
|
||||
LEFT JOIN teams t ON i."teamId" = t.id
|
||||
LEFT JOIN users u ON i."userId" = u.id
|
||||
LEFT JOIN collections c ON i."collectionId" = c.id
|
||||
WHERE i.id = $1
|
||||
`, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Integration not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* integrations.create - Create integration
|
||||
*/
|
||||
const createIntegration: BaseTool<IntegrationCreateArgs> = {
|
||||
name: 'outline_create_integration',
|
||||
description: 'Create a new integration (embed source, webhook, etc.).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
service: { type: 'string', description: 'Service name (e.g., "slack", "github", "figma")' },
|
||||
type: { type: 'string', description: 'Integration type (e.g., "embed", "linkedAccount")' },
|
||||
collection_id: { type: 'string', description: 'Link to collection (UUID, optional)' },
|
||||
events: { type: 'array', items: { type: 'string' }, description: 'Events to listen for' },
|
||||
settings: { type: 'object', description: 'Integration settings' },
|
||||
},
|
||||
required: ['service'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
|
||||
const teamResult = await pgClient.query(`SELECT id FROM teams WHERE "deletedAt" IS NULL LIMIT 1`);
|
||||
if (teamResult.rows.length === 0) throw new Error('No team found');
|
||||
|
||||
const userResult = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||
|
||||
const result = await pgClient.query(`
|
||||
INSERT INTO integrations (id, service, type, "teamId", "userId", "collectionId", events, settings, "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
RETURNING *
|
||||
`, [
|
||||
sanitizeInput(args.service),
|
||||
args.type || 'embed',
|
||||
teamResult.rows[0].id,
|
||||
userResult.rows.length > 0 ? userResult.rows[0].id : null,
|
||||
args.collection_id || null,
|
||||
args.events || [],
|
||||
args.settings ? JSON.stringify(args.settings) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Integration created' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* integrations.update - Update integration
|
||||
*/
|
||||
const updateIntegration: BaseTool<IntegrationUpdateArgs> = {
|
||||
name: 'outline_update_integration',
|
||||
description: 'Update an integration settings or events.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Integration ID (UUID)' },
|
||||
events: { type: 'array', items: { type: 'string' }, description: 'Events to listen for' },
|
||||
settings: { type: 'object', description: 'Settings to merge' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid integration ID');
|
||||
|
||||
const updates: string[] = ['"updatedAt" = NOW()'];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.events) {
|
||||
updates.push(`events = $${idx++}`);
|
||||
params.push(args.events);
|
||||
}
|
||||
if (args.settings) {
|
||||
updates.push(`settings = COALESCE(settings, '{}'::jsonb) || $${idx++}::jsonb`);
|
||||
params.push(JSON.stringify(args.settings));
|
||||
}
|
||||
|
||||
params.push(args.id);
|
||||
|
||||
const result = await pgClient.query(
|
||||
`UPDATE integrations SET ${updates.join(', ')} WHERE id = $${idx} AND "deletedAt" IS NULL RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Integration not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Integration updated' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* integrations.delete - Delete integration
|
||||
*/
|
||||
const deleteIntegration: BaseTool<{ id: string }> = {
|
||||
name: 'outline_delete_integration',
|
||||
description: 'Soft delete an integration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Integration ID (UUID)' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid integration ID');
|
||||
|
||||
const result = await pgClient.query(
|
||||
`UPDATE integrations SET "deletedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL RETURNING id, service, type`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Integration not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Integration deleted' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* integrations.sync - Trigger integration sync
|
||||
*/
|
||||
const syncIntegration: BaseTool<{ id: string }> = {
|
||||
name: 'outline_sync_integration',
|
||||
description: 'Trigger a sync for an integration. Updates lastSyncedAt timestamp.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Integration ID (UUID)' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid integration ID');
|
||||
|
||||
const result = await pgClient.query(
|
||||
`UPDATE integrations SET "updatedAt" = NOW() WHERE id = $1 AND "deletedAt" IS NULL RETURNING id, service, type, "updatedAt"`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Integration not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Sync triggered', lastSyncedAt: result.rows[0].updatedAt }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const integrationsTools: BaseTool<any>[] = [
|
||||
listIntegrations, getIntegration, createIntegration, updateIntegration, deleteIntegration, syncIntegration
|
||||
];
|
||||
173
src/tools/notifications.ts
Normal file
173
src/tools/notifications.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Notifications Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||
|
||||
interface NotificationListArgs extends PaginationArgs {
|
||||
user_id?: string;
|
||||
event?: string;
|
||||
unread_only?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* notifications.list - List notifications
|
||||
*/
|
||||
const listNotifications: BaseTool<NotificationListArgs> = {
|
||||
name: 'outline_list_notifications',
|
||||
description: 'List notifications for a user. Can filter by event type and read status.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'Filter by user ID (UUID)' },
|
||||
event: { type: 'string', description: 'Filter by event type (e.g., "documents.update")' },
|
||||
unread_only: { type: 'boolean', description: 'Only show unread notifications (default: false)' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = ['n."archivedAt" IS NULL'];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.user_id) {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
conditions.push(`n."userId" = $${idx++}`);
|
||||
params.push(args.user_id);
|
||||
}
|
||||
if (args.event) {
|
||||
conditions.push(`n.event = $${idx++}`);
|
||||
params.push(args.event);
|
||||
}
|
||||
if (args.unread_only) {
|
||||
conditions.push(`n."viewedAt" IS NULL`);
|
||||
}
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
n.id, n.event, n.data, n."viewedAt", n."emailedAt", n."createdAt",
|
||||
n."userId", n."actorId", n."documentId", n."collectionId", n."commentId",
|
||||
actor.name as "actorName",
|
||||
d.title as "documentTitle",
|
||||
c.name as "collectionName"
|
||||
FROM notifications n
|
||||
LEFT JOIN users actor ON n."actorId" = actor.id
|
||||
LEFT JOIN documents d ON n."documentId" = d.id
|
||||
LEFT JOIN collections c ON n."collectionId" = c.id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY n."createdAt" DESC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* notifications.read - Mark notification as read
|
||||
*/
|
||||
const markNotificationRead: BaseTool<{ id: string }> = {
|
||||
name: 'outline_mark_notification_read',
|
||||
description: 'Mark a notification as read (sets viewedAt).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Notification ID (UUID)' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid notification ID');
|
||||
|
||||
const result = await pgClient.query(
|
||||
`UPDATE notifications SET "viewedAt" = NOW() WHERE id = $1 AND "viewedAt" IS NULL RETURNING id, event, "viewedAt"`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Notification not found or already read');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Marked as read' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* notifications.readAll - Mark all notifications as read
|
||||
*/
|
||||
const markAllNotificationsRead: BaseTool<{ user_id: string }> = {
|
||||
name: 'outline_mark_all_notifications_read',
|
||||
description: 'Mark all notifications as read for a user.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
|
||||
const result = await pgClient.query(
|
||||
`UPDATE notifications SET "viewedAt" = NOW() WHERE "userId" = $1 AND "viewedAt" IS NULL RETURNING id`,
|
||||
[args.user_id]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ markedCount: result.rows.length, message: 'All notifications marked as read' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* notifications.settings - Get notification settings for user
|
||||
*/
|
||||
const getNotificationSettings: BaseTool<{ user_id: string }> = {
|
||||
name: 'outline_get_notification_settings',
|
||||
description: 'Get notification settings/preferences for a user.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
|
||||
const result = await pgClient.query(
|
||||
`SELECT id, name, email, "notificationSettings" FROM users WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||
[args.user_id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('User not found');
|
||||
|
||||
// Also get notification stats
|
||||
const stats = await pgClient.query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE "viewedAt" IS NULL) as unread
|
||||
FROM notifications
|
||||
WHERE "userId" = $1 AND "archivedAt" IS NULL
|
||||
`, [args.user_id]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
user: { id: result.rows[0].id, name: result.rows[0].name },
|
||||
settings: result.rows[0].notificationSettings,
|
||||
stats: stats.rows[0],
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const notificationsTools: BaseTool<any>[] = [
|
||||
listNotifications, markNotificationRead, markAllNotificationsRead, getNotificationSettings
|
||||
];
|
||||
192
src/tools/subscriptions.ts
Normal file
192
src/tools/subscriptions.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Subscriptions Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||
|
||||
interface SubscriptionListArgs extends PaginationArgs {
|
||||
user_id?: string;
|
||||
document_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* subscriptions.list - List subscriptions
|
||||
*/
|
||||
const listSubscriptions: BaseTool<SubscriptionListArgs> = {
|
||||
name: 'outline_list_subscriptions',
|
||||
description: 'List document subscriptions. Subscriptions determine who gets notified of document changes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'Filter by user ID (UUID)' },
|
||||
document_id: { type: 'string', description: 'Filter by document ID (UUID)' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.user_id) {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
conditions.push(`s."userId" = $${idx++}`);
|
||||
params.push(args.user_id);
|
||||
}
|
||||
if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||
conditions.push(`s."documentId" = $${idx++}`);
|
||||
params.push(args.document_id);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
s.id, s."userId", s."documentId", s.event, s."createdAt",
|
||||
u.name as "userName", u.email as "userEmail",
|
||||
d.title as "documentTitle"
|
||||
FROM subscriptions s
|
||||
LEFT JOIN users u ON s."userId" = u.id
|
||||
LEFT JOIN documents d ON s."documentId" = d.id
|
||||
${whereClause}
|
||||
ORDER BY s."createdAt" DESC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* subscriptions.create - Subscribe to document
|
||||
*/
|
||||
const subscribeToDocument: BaseTool<{ document_id: string; user_id: string; event?: string }> = {
|
||||
name: 'outline_subscribe_to_document',
|
||||
description: 'Subscribe a user to document notifications.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: { type: 'string', description: 'Document ID (UUID)' },
|
||||
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||
event: { type: 'string', description: 'Event type to subscribe to (default: all)' },
|
||||
},
|
||||
required: ['document_id', 'user_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
|
||||
// Check if already subscribed
|
||||
const existing = await pgClient.query(
|
||||
`SELECT id FROM subscriptions WHERE "documentId" = $1 AND "userId" = $2`,
|
||||
[args.document_id, args.user_id]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
throw new Error('User already subscribed to this document');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(`
|
||||
INSERT INTO subscriptions (id, "documentId", "userId", event, "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, NOW(), NOW())
|
||||
RETURNING *
|
||||
`, [args.document_id, args.user_id, args.event || 'documents.update']);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Subscribed successfully' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* subscriptions.delete - Unsubscribe from document
|
||||
*/
|
||||
const unsubscribeFromDocument: BaseTool<{ id?: string; document_id?: string; user_id?: string }> = {
|
||||
name: 'outline_unsubscribe_from_document',
|
||||
description: 'Unsubscribe from document notifications.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Subscription ID (UUID)' },
|
||||
document_id: { type: 'string', description: 'Document ID (requires user_id)' },
|
||||
user_id: { type: 'string', description: 'User ID (requires document_id)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
let result;
|
||||
|
||||
if (args.id) {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid id');
|
||||
result = await pgClient.query(
|
||||
`DELETE FROM subscriptions WHERE id = $1 RETURNING id, "documentId", "userId"`,
|
||||
[args.id]
|
||||
);
|
||||
} else if (args.document_id && args.user_id) {
|
||||
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
result = await pgClient.query(
|
||||
`DELETE FROM subscriptions WHERE "documentId" = $1 AND "userId" = $2 RETURNING id, "documentId", "userId"`,
|
||||
[args.document_id, args.user_id]
|
||||
);
|
||||
} else {
|
||||
throw new Error('Either id or (document_id + user_id) required');
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Subscription not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Unsubscribed successfully' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* subscriptions.settings - Get subscription settings
|
||||
*/
|
||||
const getSubscriptionSettings: BaseTool<{ user_id: string }> = {
|
||||
name: 'outline_get_subscription_settings',
|
||||
description: 'Get subscription summary and settings for a user.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
|
||||
const subscriptions = await pgClient.query(`
|
||||
SELECT s.id, s."documentId", s.event, d.title as "documentTitle"
|
||||
FROM subscriptions s
|
||||
LEFT JOIN documents d ON s."documentId" = d.id
|
||||
WHERE s."userId" = $1
|
||||
ORDER BY s."createdAt" DESC
|
||||
`, [args.user_id]);
|
||||
|
||||
const userSettings = await pgClient.query(
|
||||
`SELECT "notificationSettings" FROM users WHERE id = $1`,
|
||||
[args.user_id]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({
|
||||
subscriptions: subscriptions.rows,
|
||||
totalSubscriptions: subscriptions.rows.length,
|
||||
userSettings: userSettings.rows[0]?.notificationSettings || {},
|
||||
}, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const subscriptionsTools: BaseTool<any>[] = [
|
||||
listSubscriptions, subscribeToDocument, unsubscribeFromDocument, getSubscriptionSettings
|
||||
];
|
||||
222
src/tools/teams.ts
Normal file
222
src/tools/teams.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Teams Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse } from '../types/tools.js';
|
||||
import { isValidUUID, sanitizeInput } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* teams.info - Get team details
|
||||
*/
|
||||
const getTeam: BaseTool<{ id?: string }> = {
|
||||
name: 'outline_get_team',
|
||||
description: 'Get detailed information about a team (workspace). If no ID provided, returns the first/default team.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Team ID (UUID, optional)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
let query = `
|
||||
SELECT
|
||||
t.id, t.name, t.subdomain, t.domain, t."avatarUrl",
|
||||
t.sharing, t."documentEmbeds", t."guestSignin", t."inviteRequired",
|
||||
t."collaborativeEditing", t."defaultUserRole", t."memberCollectionCreate",
|
||||
t."memberTeamCreate", t."passkeysEnabled", t.description, t.preferences,
|
||||
t."lastActiveAt", t."suspendedAt", t."createdAt", t."updatedAt",
|
||||
(SELECT COUNT(*) FROM users WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "userCount",
|
||||
(SELECT COUNT(*) FROM collections WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "collectionCount",
|
||||
(SELECT COUNT(*) FROM documents WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "documentCount"
|
||||
FROM teams t
|
||||
WHERE t."deletedAt" IS NULL
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
if (args.id) {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid team ID format');
|
||||
query += ` AND t.id = $1`;
|
||||
params.push(args.id);
|
||||
}
|
||||
query += ` LIMIT 1`;
|
||||
|
||||
const result = await pgClient.query(query, params);
|
||||
if (result.rows.length === 0) throw new Error('Team not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* teams.update - Update team settings
|
||||
*/
|
||||
const updateTeam: BaseTool<{
|
||||
id: string;
|
||||
name?: string;
|
||||
sharing?: boolean;
|
||||
document_embeds?: boolean;
|
||||
guest_signin?: boolean;
|
||||
invite_required?: boolean;
|
||||
default_user_role?: string;
|
||||
}> = {
|
||||
name: 'outline_update_team',
|
||||
description: 'Update team settings and preferences.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Team ID (UUID)' },
|
||||
name: { type: 'string', description: 'Team name' },
|
||||
sharing: { type: 'boolean', description: 'Allow document sharing' },
|
||||
document_embeds: { type: 'boolean', description: 'Allow document embeds' },
|
||||
guest_signin: { type: 'boolean', description: 'Allow guest signin' },
|
||||
invite_required: { type: 'boolean', description: 'Require invite to join' },
|
||||
default_user_role: { type: 'string', enum: ['admin', 'member', 'viewer'], description: 'Default role for new users' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid team ID format');
|
||||
|
||||
const updates: string[] = ['"updatedAt" = NOW()'];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.name) { updates.push(`name = $${idx++}`); params.push(sanitizeInput(args.name)); }
|
||||
if (args.sharing !== undefined) { updates.push(`sharing = $${idx++}`); params.push(args.sharing); }
|
||||
if (args.document_embeds !== undefined) { updates.push(`"documentEmbeds" = $${idx++}`); params.push(args.document_embeds); }
|
||||
if (args.guest_signin !== undefined) { updates.push(`"guestSignin" = $${idx++}`); params.push(args.guest_signin); }
|
||||
if (args.invite_required !== undefined) { updates.push(`"inviteRequired" = $${idx++}`); params.push(args.invite_required); }
|
||||
if (args.default_user_role) { updates.push(`"defaultUserRole" = $${idx++}`); params.push(args.default_user_role); }
|
||||
|
||||
params.push(args.id);
|
||||
|
||||
const result = await pgClient.query(
|
||||
`UPDATE teams SET ${updates.join(', ')} WHERE id = $${idx} AND "deletedAt" IS NULL RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Team not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Team updated' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* teams.stats - Get team statistics
|
||||
*/
|
||||
const getTeamStats: BaseTool<{ id?: string }> = {
|
||||
name: 'outline_get_team_stats',
|
||||
description: 'Get comprehensive statistics for a team including users, documents, collections, and activity.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Team ID (UUID, optional - uses default team)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
let teamCondition = '';
|
||||
const params: any[] = [];
|
||||
|
||||
if (args.id) {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid team ID format');
|
||||
teamCondition = `AND "teamId" = $1`;
|
||||
params.push(args.id);
|
||||
}
|
||||
|
||||
const stats = await pgClient.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users WHERE "deletedAt" IS NULL ${teamCondition}) as "totalUsers",
|
||||
(SELECT COUNT(*) FROM users WHERE role = 'admin' AND "deletedAt" IS NULL ${teamCondition}) as "adminUsers",
|
||||
(SELECT COUNT(*) FROM users WHERE "suspendedAt" IS NOT NULL AND "deletedAt" IS NULL ${teamCondition}) as "suspendedUsers",
|
||||
(SELECT COUNT(*) FROM documents WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "totalDocuments",
|
||||
(SELECT COUNT(*) FROM documents WHERE template = true AND "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "templateDocuments",
|
||||
(SELECT COUNT(*) FROM documents WHERE "publishedAt" IS NOT NULL AND "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "publishedDocuments",
|
||||
(SELECT COUNT(*) FROM collections WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'c."teamId"')}) as "totalCollections",
|
||||
(SELECT COUNT(*) FROM groups WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'g."teamId"')}) as "totalGroups",
|
||||
(SELECT COUNT(*) FROM shares ${args.id ? 'WHERE "teamId" = $1' : ''}) as "totalShares",
|
||||
(SELECT COUNT(*) FROM integrations WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'i."teamId"')}) as "totalIntegrations"
|
||||
`, params);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: stats.rows[0] }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* teams.domains - List team domains
|
||||
*/
|
||||
const listTeamDomains: BaseTool<{ team_id?: string }> = {
|
||||
name: 'outline_list_team_domains',
|
||||
description: 'List allowed domains for a team. Domains control who can sign up.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
team_id: { type: 'string', description: 'Team ID (UUID, optional)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
let query = `
|
||||
SELECT
|
||||
td.id, td.name, td."teamId", td."createdById", td."createdAt",
|
||||
u.name as "createdByName", t.name as "teamName"
|
||||
FROM team_domains td
|
||||
LEFT JOIN users u ON td."createdById" = u.id
|
||||
LEFT JOIN teams t ON td."teamId" = t.id
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
if (args.team_id) {
|
||||
if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id format');
|
||||
query += ` WHERE td."teamId" = $1`;
|
||||
params.push(args.team_id);
|
||||
}
|
||||
query += ` ORDER BY td."createdAt" DESC`;
|
||||
|
||||
const result = await pgClient.query(query, params);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* teams.updateSettings - Update team preferences
|
||||
*/
|
||||
const updateTeamSettings: BaseTool<{ id: string; preferences: Record<string, any> }> = {
|
||||
name: 'outline_update_team_settings',
|
||||
description: 'Update team preferences (JSON settings object).',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Team ID (UUID)' },
|
||||
preferences: { type: 'object', description: 'Preferences object to merge' },
|
||||
},
|
||||
required: ['id', 'preferences'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid team ID format');
|
||||
|
||||
const result = await pgClient.query(
|
||||
`UPDATE teams SET preferences = COALESCE(preferences, '{}'::jsonb) || $1::jsonb, "updatedAt" = NOW()
|
||||
WHERE id = $2 AND "deletedAt" IS NULL
|
||||
RETURNING id, name, preferences`,
|
||||
[JSON.stringify(args.preferences), args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Team not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Settings updated' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const teamsTools: BaseTool<any>[] = [getTeam, updateTeam, getTeamStats, listTeamDomains, updateTeamSettings];
|
||||
223
src/tools/templates.ts
Normal file
223
src/tools/templates.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Templates Tools
|
||||
* Templates are documents with template=true
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js';
|
||||
|
||||
interface TemplateListArgs extends PaginationArgs {
|
||||
collection_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* templates.list - List templates
|
||||
*/
|
||||
const listTemplates: BaseTool<TemplateListArgs> = {
|
||||
name: 'outline_list_templates',
|
||||
description: 'List document templates. Templates are reusable document structures.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = ['d.template = true', 'd."deletedAt" IS NULL'];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.collection_id) {
|
||||
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
conditions.push(`d."collectionId" = $${idx++}`);
|
||||
params.push(args.collection_id);
|
||||
}
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
d.id, d.title, d.emoji, d."collectionId", d."createdById",
|
||||
d."createdAt", d."updatedAt",
|
||||
c.name as "collectionName",
|
||||
u.name as "createdByName",
|
||||
(SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount"
|
||||
FROM documents d
|
||||
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||
LEFT JOIN users u ON d."createdById" = u.id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY d."updatedAt" DESC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...params, limit, offset]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* templates.info - Get template details
|
||||
*/
|
||||
const getTemplate: BaseTool<{ id: string }> = {
|
||||
name: 'outline_get_template',
|
||||
description: 'Get detailed information about a template including its content.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: 'Template ID (UUID)' },
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) throw new Error('Invalid template ID');
|
||||
|
||||
const result = await pgClient.query(`
|
||||
SELECT
|
||||
d.*, c.name as "collectionName", u.name as "createdByName",
|
||||
(SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount"
|
||||
FROM documents d
|
||||
LEFT JOIN collections c ON d."collectionId" = c.id
|
||||
LEFT JOIN users u ON d."createdById" = u.id
|
||||
WHERE d.id = $1 AND d.template = true AND d."deletedAt" IS NULL
|
||||
`, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Template not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* templates.create - Create document from template
|
||||
*/
|
||||
const createFromTemplate: BaseTool<{ template_id: string; title: string; collection_id?: string; parent_document_id?: string }> = {
|
||||
name: 'outline_create_from_template',
|
||||
description: 'Create a new document from a template.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
template_id: { type: 'string', description: 'Template ID (UUID)' },
|
||||
title: { type: 'string', description: 'Title for the new document' },
|
||||
collection_id: { type: 'string', description: 'Collection ID (UUID, optional - uses template collection)' },
|
||||
parent_document_id: { type: 'string', description: 'Parent document ID (UUID, optional)' },
|
||||
},
|
||||
required: ['template_id', 'title'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id');
|
||||
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
if (args.parent_document_id && !isValidUUID(args.parent_document_id)) throw new Error('Invalid parent_document_id');
|
||||
|
||||
// Get template
|
||||
const template = await pgClient.query(
|
||||
`SELECT * FROM documents WHERE id = $1 AND template = true AND "deletedAt" IS NULL`,
|
||||
[args.template_id]
|
||||
);
|
||||
|
||||
if (template.rows.length === 0) throw new Error('Template not found');
|
||||
|
||||
const t = template.rows[0];
|
||||
|
||||
// Get user
|
||||
const user = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||
const userId = user.rows.length > 0 ? user.rows[0].id : t.createdById;
|
||||
|
||||
// Create document from template
|
||||
const result = await pgClient.query(`
|
||||
INSERT INTO documents (
|
||||
id, title, text, emoji, "collectionId", "teamId", "parentDocumentId",
|
||||
"templateId", "createdById", "lastModifiedById", template,
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
VALUES (
|
||||
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $8, false, NOW(), NOW()
|
||||
)
|
||||
RETURNING *
|
||||
`, [
|
||||
sanitizeInput(args.title),
|
||||
t.text,
|
||||
t.emoji,
|
||||
args.collection_id || t.collectionId,
|
||||
t.teamId,
|
||||
args.parent_document_id || null,
|
||||
args.template_id,
|
||||
userId,
|
||||
]);
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Document created from template' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* templates.convert - Convert document to template
|
||||
*/
|
||||
const convertToTemplate: BaseTool<{ document_id: string }> = {
|
||||
name: 'outline_convert_to_template',
|
||||
description: 'Convert an existing document to a template.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: { type: 'string', description: 'Document ID to convert (UUID)' },
|
||||
},
|
||||
required: ['document_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||
|
||||
const result = await pgClient.query(`
|
||||
UPDATE documents
|
||||
SET template = true, "updatedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL AND template = false
|
||||
RETURNING id, title, template
|
||||
`, [args.document_id]);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Document not found or already a template');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to template' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* templates.unconvert - Convert template back to document
|
||||
*/
|
||||
const convertFromTemplate: BaseTool<{ template_id: string }> = {
|
||||
name: 'outline_convert_from_template',
|
||||
description: 'Convert a template back to a regular document.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
template_id: { type: 'string', description: 'Template ID to convert (UUID)' },
|
||||
},
|
||||
required: ['template_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id');
|
||||
|
||||
const result = await pgClient.query(`
|
||||
UPDATE documents
|
||||
SET template = false, "updatedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL AND template = true
|
||||
RETURNING id, title, template
|
||||
`, [args.template_id]);
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Template not found or already a document');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to document' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const templatesTools: BaseTool<any>[] = [
|
||||
listTemplates, getTemplate, createFromTemplate, convertToTemplate, convertFromTemplate
|
||||
];
|
||||
243
src/tools/user-permissions.ts
Normal file
243
src/tools/user-permissions.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - User Permissions Tools
|
||||
* Document/Collection level permission management
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||
|
||||
interface PermissionListArgs extends PaginationArgs {
|
||||
user_id?: string;
|
||||
document_id?: string;
|
||||
collection_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* user_permissions.list - List user permissions
|
||||
*/
|
||||
const listUserPermissions: BaseTool<PermissionListArgs> = {
|
||||
name: 'outline_list_user_permissions',
|
||||
description: 'List user permissions on documents and collections.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'Filter by user ID (UUID)' },
|
||||
document_id: { type: 'string', description: 'Filter by document ID (UUID)' },
|
||||
collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' },
|
||||
limit: { type: 'number', description: 'Max results (default: 25)' },
|
||||
offset: { type: 'number', description: 'Skip results (default: 0)' },
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
|
||||
const results: any = { documentPermissions: [], collectionPermissions: [] };
|
||||
|
||||
// Document permissions (user_permissions table)
|
||||
if (!args.collection_id) {
|
||||
const docConditions: string[] = [];
|
||||
const docParams: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.user_id) {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
docConditions.push(`up."userId" = $${idx++}`);
|
||||
docParams.push(args.user_id);
|
||||
}
|
||||
if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||
docConditions.push(`up."documentId" = $${idx++}`);
|
||||
docParams.push(args.document_id);
|
||||
}
|
||||
|
||||
const docWhere = docConditions.length > 0 ? `WHERE ${docConditions.join(' AND ')}` : '';
|
||||
|
||||
const docResult = await pgClient.query(`
|
||||
SELECT
|
||||
up.id, up."userId", up."documentId", up.permission,
|
||||
up."createdById", up."createdAt", up."updatedAt",
|
||||
u.name as "userName", u.email as "userEmail",
|
||||
d.title as "documentTitle"
|
||||
FROM user_permissions up
|
||||
LEFT JOIN users u ON up."userId" = u.id
|
||||
LEFT JOIN documents d ON up."documentId" = d.id
|
||||
${docWhere}
|
||||
ORDER BY up."createdAt" DESC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...docParams, limit, offset]);
|
||||
|
||||
results.documentPermissions = docResult.rows;
|
||||
}
|
||||
|
||||
// Collection permissions (collection_users table)
|
||||
if (!args.document_id) {
|
||||
const colConditions: string[] = [];
|
||||
const colParams: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (args.user_id) {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
colConditions.push(`cu."userId" = $${idx++}`);
|
||||
colParams.push(args.user_id);
|
||||
}
|
||||
if (args.collection_id) {
|
||||
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
|
||||
colConditions.push(`cu."collectionId" = $${idx++}`);
|
||||
colParams.push(args.collection_id);
|
||||
}
|
||||
|
||||
const colWhere = colConditions.length > 0 ? `WHERE ${colConditions.join(' AND ')}` : '';
|
||||
|
||||
const colResult = await pgClient.query(`
|
||||
SELECT
|
||||
cu."userId", cu."collectionId", cu.permission,
|
||||
cu."createdById", cu."createdAt", cu."updatedAt",
|
||||
u.name as "userName", u.email as "userEmail",
|
||||
c.name as "collectionName"
|
||||
FROM collection_users cu
|
||||
LEFT JOIN users u ON cu."userId" = u.id
|
||||
LEFT JOIN collections c ON cu."collectionId" = c.id
|
||||
${colWhere}
|
||||
ORDER BY cu."createdAt" DESC
|
||||
LIMIT $${idx++} OFFSET $${idx}
|
||||
`, [...colParams, limit, offset]);
|
||||
|
||||
results.collectionPermissions = colResult.rows;
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: results, pagination: { limit, offset } }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* user_permissions.grant - Grant permission to user
|
||||
*/
|
||||
const grantUserPermission: BaseTool<{ user_id: string; document_id?: string; collection_id?: string; permission: string }> = {
|
||||
name: 'outline_grant_user_permission',
|
||||
description: 'Grant permission to a user on a document or collection.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||
document_id: { type: 'string', description: 'Document ID (UUID) - provide either this or collection_id' },
|
||||
collection_id: { type: 'string', description: 'Collection ID (UUID) - provide either this or document_id' },
|
||||
permission: { type: 'string', description: 'Permission level: read_write, read, or admin (for collections)' },
|
||||
},
|
||||
required: ['user_id', 'permission'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
if (!args.document_id && !args.collection_id) throw new Error('Either document_id or collection_id required');
|
||||
if (args.document_id && args.collection_id) throw new Error('Provide only one of document_id or collection_id');
|
||||
|
||||
const creatorResult = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
|
||||
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_id;
|
||||
|
||||
let result;
|
||||
|
||||
if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||
|
||||
// Check if permission already exists
|
||||
const existing = await pgClient.query(
|
||||
`SELECT id FROM user_permissions WHERE "userId" = $1 AND "documentId" = $2`,
|
||||
[args.user_id, args.document_id]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Update existing
|
||||
result = await pgClient.query(`
|
||||
UPDATE user_permissions
|
||||
SET permission = $1, "updatedAt" = NOW()
|
||||
WHERE "userId" = $2 AND "documentId" = $3
|
||||
RETURNING *
|
||||
`, [args.permission, args.user_id, args.document_id]);
|
||||
} else {
|
||||
// Create new
|
||||
result = await pgClient.query(`
|
||||
INSERT INTO user_permissions (id, "userId", "documentId", permission, "createdById", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING *
|
||||
`, [args.user_id, args.document_id, args.permission, createdById]);
|
||||
}
|
||||
} else {
|
||||
if (!isValidUUID(args.collection_id!)) throw new Error('Invalid collection_id');
|
||||
|
||||
// Check if permission already exists
|
||||
const existing = await pgClient.query(
|
||||
`SELECT "userId" FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2`,
|
||||
[args.user_id, args.collection_id]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Update existing
|
||||
result = await pgClient.query(`
|
||||
UPDATE collection_users
|
||||
SET permission = $1, "updatedAt" = NOW()
|
||||
WHERE "userId" = $2 AND "collectionId" = $3
|
||||
RETURNING *
|
||||
`, [args.permission, args.user_id, args.collection_id]);
|
||||
} else {
|
||||
// Create new
|
||||
result = await pgClient.query(`
|
||||
INSERT INTO collection_users ("userId", "collectionId", permission, "createdById", "createdAt", "updatedAt")
|
||||
VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING *
|
||||
`, [args.user_id, args.collection_id, args.permission, createdById]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Permission granted' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* user_permissions.revoke - Revoke permission from user
|
||||
*/
|
||||
const revokeUserPermission: BaseTool<{ user_id: string; document_id?: string; collection_id?: string }> = {
|
||||
name: 'outline_revoke_user_permission',
|
||||
description: 'Revoke permission from a user on a document or collection.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
user_id: { type: 'string', description: 'User ID (UUID)' },
|
||||
document_id: { type: 'string', description: 'Document ID (UUID)' },
|
||||
collection_id: { type: 'string', description: 'Collection ID (UUID)' },
|
||||
},
|
||||
required: ['user_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.user_id)) throw new Error('Invalid user_id');
|
||||
if (!args.document_id && !args.collection_id) throw new Error('Either document_id or collection_id required');
|
||||
|
||||
let result;
|
||||
|
||||
if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
|
||||
result = await pgClient.query(
|
||||
`DELETE FROM user_permissions WHERE "userId" = $1 AND "documentId" = $2 RETURNING *`,
|
||||
[args.user_id, args.document_id]
|
||||
);
|
||||
} else {
|
||||
if (!isValidUUID(args.collection_id!)) throw new Error('Invalid collection_id');
|
||||
result = await pgClient.query(
|
||||
`DELETE FROM collection_users WHERE "userId" = $1 AND "collectionId" = $2 RETURNING *`,
|
||||
[args.user_id, args.collection_id]
|
||||
);
|
||||
}
|
||||
|
||||
if (result.rows.length === 0) throw new Error('Permission not found');
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Permission revoked' }, null, 2) }],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const userPermissionsTools: BaseTool<any>[] = [listUserPermissions, grantUserPermission, revokeUserPermission];
|
||||
Reference in New Issue
Block a user