feat: Initial release MCP Outline PostgreSQL v1.0.0
86 tools across 12 modules for direct PostgreSQL access to Outline Wiki: - Documents (19), Collections (14), Users (9), Groups (8) - Comments (6), Shares (5), Revisions (3), Events (3) - Attachments (5), File Operations (4), OAuth (8), Auth (2) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
490
src/tools/attachments.ts
Normal file
490
src/tools/attachments.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Attachments Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, CreateAttachmentArgs, GetAttachmentArgs, PaginationArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||
|
||||
interface AttachmentListArgs extends PaginationArgs {
|
||||
document_id?: string;
|
||||
user_id?: string;
|
||||
team_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* attachments.list - List attachments with optional filters
|
||||
*/
|
||||
const listAttachments: BaseTool<AttachmentListArgs> = {
|
||||
name: 'outline_attachments_list',
|
||||
description: 'List file attachments with optional filtering by document, user, or team. Supports pagination.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by document ID (UUID)',
|
||||
},
|
||||
user_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by user ID who uploaded (UUID)',
|
||||
},
|
||||
team_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by team ID (UUID)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results (default: 25, max: 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip (default: 0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = ['a."deletedAt" IS NULL'];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
conditions.push(`a."documentId" = $${paramIndex++}`);
|
||||
params.push(args.document_id);
|
||||
}
|
||||
|
||||
if (args.user_id) {
|
||||
if (!isValidUUID(args.user_id)) {
|
||||
throw new Error('Invalid user_id format');
|
||||
}
|
||||
conditions.push(`a."userId" = $${paramIndex++}`);
|
||||
params.push(args.user_id);
|
||||
}
|
||||
|
||||
if (args.team_id) {
|
||||
if (!isValidUUID(args.team_id)) {
|
||||
throw new Error('Invalid team_id format');
|
||||
}
|
||||
conditions.push(`a."teamId" = $${paramIndex++}`);
|
||||
params.push(args.team_id);
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
a.id,
|
||||
a.key,
|
||||
a.url,
|
||||
a."contentType",
|
||||
a.size,
|
||||
a.acl,
|
||||
a."documentId",
|
||||
a."userId",
|
||||
a."teamId",
|
||||
a."createdAt",
|
||||
a."updatedAt",
|
||||
d.title as "documentTitle",
|
||||
u.name as "uploadedByName",
|
||||
u.email as "uploadedByEmail"
|
||||
FROM attachments a
|
||||
LEFT JOIN documents d ON a."documentId" = d.id
|
||||
LEFT JOIN users u ON a."userId" = u.id
|
||||
${whereClause}
|
||||
ORDER BY a."createdAt" DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await pgClient.query(query, params);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total: result.rows.length,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* attachments.info - Get detailed information about a specific attachment
|
||||
*/
|
||||
const getAttachment: BaseTool<GetAttachmentArgs> = {
|
||||
name: 'outline_attachments_info',
|
||||
description: 'Get detailed information about a specific attachment by ID.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Attachment ID (UUID)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid attachment ID format');
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
a.id,
|
||||
a.key,
|
||||
a.url,
|
||||
a."contentType",
|
||||
a.size,
|
||||
a.acl,
|
||||
a."documentId",
|
||||
a."userId",
|
||||
a."teamId",
|
||||
a."createdAt",
|
||||
a."updatedAt",
|
||||
a."deletedAt",
|
||||
d.title as "documentTitle",
|
||||
d."collectionId",
|
||||
u.name as "uploadedByName",
|
||||
u.email as "uploadedByEmail",
|
||||
t.name as "teamName"
|
||||
FROM attachments a
|
||||
LEFT JOIN documents d ON a."documentId" = d.id
|
||||
LEFT JOIN users u ON a."userId" = u.id
|
||||
LEFT JOIN teams t ON a."teamId" = t.id
|
||||
WHERE a.id = $1
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Attachment not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* attachments.create - Create a new attachment record
|
||||
*/
|
||||
const createAttachment: BaseTool<CreateAttachmentArgs> = {
|
||||
name: 'outline_attachments_create',
|
||||
description: 'Create a new attachment record. Note: This creates the database record only, actual file upload is handled separately.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Attachment filename/key',
|
||||
},
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Document ID to attach to (UUID, optional)',
|
||||
},
|
||||
content_type: {
|
||||
type: 'string',
|
||||
description: 'MIME type (e.g., "image/png", "application/pdf")',
|
||||
},
|
||||
size: {
|
||||
type: 'number',
|
||||
description: 'File size in bytes',
|
||||
},
|
||||
},
|
||||
required: ['name', 'content_type', 'size'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (args.document_id && !isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
|
||||
// Verify document exists if provided
|
||||
if (args.document_id) {
|
||||
const docCheck = await pgClient.query(
|
||||
'SELECT id, "teamId" FROM documents WHERE id = $1 AND "deletedAt" IS NULL',
|
||||
[args.document_id]
|
||||
);
|
||||
|
||||
if (docCheck.rows.length === 0) {
|
||||
throw new Error('Document not found or deleted');
|
||||
}
|
||||
}
|
||||
|
||||
// Get first admin user and team
|
||||
const userQuery = await pgClient.query(
|
||||
'SELECT u.id, u."teamId" FROM users u WHERE u."isAdmin" = true AND u."deletedAt" IS NULL LIMIT 1'
|
||||
);
|
||||
|
||||
if (userQuery.rows.length === 0) {
|
||||
throw new Error('No valid user found to create attachment');
|
||||
}
|
||||
|
||||
const userId = userQuery.rows[0].id;
|
||||
const teamId = userQuery.rows[0].teamId;
|
||||
|
||||
// Generate URL and key (in real implementation, this would be S3/storage URL)
|
||||
const key = `attachments/${Date.now()}-${args.name}`;
|
||||
const url = `/api/attachments.redirect?id=PLACEHOLDER`;
|
||||
|
||||
const query = `
|
||||
INSERT INTO attachments (
|
||||
key,
|
||||
url,
|
||||
"contentType",
|
||||
size,
|
||||
acl,
|
||||
"documentId",
|
||||
"userId",
|
||||
"teamId",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [
|
||||
key,
|
||||
url,
|
||||
args.content_type,
|
||||
args.size,
|
||||
'private', // Default ACL
|
||||
args.document_id || null,
|
||||
userId,
|
||||
teamId,
|
||||
]);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* attachments.delete - Delete an attachment (soft delete)
|
||||
*/
|
||||
const deleteAttachment: BaseTool<GetAttachmentArgs> = {
|
||||
name: 'outline_attachments_delete',
|
||||
description: 'Soft delete an attachment. The attachment record is marked as deleted but not removed from the database.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Attachment ID (UUID)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid attachment ID format');
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE attachments
|
||||
SET
|
||||
"deletedAt" = NOW(),
|
||||
"updatedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, key, "documentId"
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Attachment not found or already deleted');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: 'Attachment deleted successfully',
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* attachments.stats - Get attachment statistics
|
||||
*/
|
||||
const getAttachmentStats: BaseTool<{ team_id?: string; document_id?: string }> = {
|
||||
name: 'outline_attachments_stats',
|
||||
description: 'Get statistics about attachments including total count, size, and breakdown by content type.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
team_id: {
|
||||
type: 'string',
|
||||
description: 'Filter statistics by team ID (UUID)',
|
||||
},
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Filter statistics by document ID (UUID)',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const conditions: string[] = ['a."deletedAt" IS NULL'];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (args.team_id) {
|
||||
if (!isValidUUID(args.team_id)) {
|
||||
throw new Error('Invalid team_id format');
|
||||
}
|
||||
conditions.push(`a."teamId" = $${paramIndex++}`);
|
||||
params.push(args.team_id);
|
||||
}
|
||||
|
||||
if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
conditions.push(`a."documentId" = $${paramIndex++}`);
|
||||
params.push(args.document_id);
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
|
||||
// Overall statistics
|
||||
const overallStatsQuery = await pgClient.query(
|
||||
`SELECT
|
||||
COUNT(*) as "totalAttachments",
|
||||
SUM(size) as "totalSize",
|
||||
AVG(size) as "averageSize",
|
||||
COUNT(DISTINCT "documentId") as "documentsWithAttachments",
|
||||
COUNT(DISTINCT "userId") as "uniqueUploaders"
|
||||
FROM attachments a
|
||||
${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
// By content type
|
||||
const byContentTypeQuery = await pgClient.query(
|
||||
`SELECT
|
||||
a."contentType",
|
||||
COUNT(*) as count,
|
||||
SUM(size) as "totalSize"
|
||||
FROM attachments a
|
||||
${whereClause}
|
||||
GROUP BY a."contentType"
|
||||
ORDER BY count DESC`,
|
||||
params
|
||||
);
|
||||
|
||||
// Top uploaders
|
||||
const topUploadersQuery = await pgClient.query(
|
||||
`SELECT
|
||||
a."userId",
|
||||
u.name as "userName",
|
||||
u.email as "userEmail",
|
||||
COUNT(*) as "attachmentCount",
|
||||
SUM(a.size) as "totalSize"
|
||||
FROM attachments a
|
||||
LEFT JOIN users u ON a."userId" = u.id
|
||||
${whereClause}
|
||||
GROUP BY a."userId", u.name, u.email
|
||||
ORDER BY "attachmentCount" DESC
|
||||
LIMIT 10`,
|
||||
params
|
||||
);
|
||||
|
||||
// Recent uploads
|
||||
const recentUploadsQuery = await pgClient.query(
|
||||
`SELECT
|
||||
a.id,
|
||||
a.key,
|
||||
a."contentType",
|
||||
a.size,
|
||||
a."createdAt",
|
||||
u.name as "uploadedByName",
|
||||
d.title as "documentTitle"
|
||||
FROM attachments a
|
||||
LEFT JOIN users u ON a."userId" = u.id
|
||||
LEFT JOIN documents d ON a."documentId" = d.id
|
||||
${whereClause}
|
||||
ORDER BY a."createdAt" DESC
|
||||
LIMIT 10`,
|
||||
params
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
overall: overallStatsQuery.rows[0],
|
||||
byContentType: byContentTypeQuery.rows,
|
||||
topUploaders: topUploadersQuery.rows,
|
||||
recentUploads: recentUploadsQuery.rows,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all attachment tools
|
||||
export const attachmentsTools: BaseTool<any>[] = [
|
||||
listAttachments,
|
||||
getAttachment,
|
||||
createAttachment,
|
||||
deleteAttachment,
|
||||
getAttachmentStats,
|
||||
];
|
||||
159
src/tools/auth.ts
Normal file
159
src/tools/auth.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Authentication Tools
|
||||
* Provides authentication information and configuration
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse } from '../types/tools.js';
|
||||
|
||||
interface AuthenticationProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
teamId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* auth.info - Get current authentication details
|
||||
*/
|
||||
const getAuthInfo: BaseTool<Record<string, never>> = {
|
||||
name: 'outline_auth_info',
|
||||
description: 'Get information about the current authentication context. Returns team and user information based on the database connection.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
// Get general authentication statistics
|
||||
const statsQuery = await pgClient.query(`
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users WHERE "deletedAt" IS NULL) as total_users,
|
||||
(SELECT COUNT(*) FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL) as admin_users,
|
||||
(SELECT COUNT(*) FROM users WHERE "isSuspended" = true) as suspended_users,
|
||||
(SELECT COUNT(*) FROM teams) as total_teams,
|
||||
(SELECT COUNT(*) FROM oauth_clients) as oauth_clients,
|
||||
(SELECT COUNT(*) FROM oauth_authentications) as oauth_authentications
|
||||
`);
|
||||
|
||||
const stats = statsQuery.rows[0];
|
||||
|
||||
// Get recent authentication activity
|
||||
const recentActivity = await pgClient.query(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.name,
|
||||
u.email,
|
||||
u."lastActiveAt",
|
||||
u."lastSignedInAt",
|
||||
u."isAdmin",
|
||||
u."isSuspended"
|
||||
FROM users u
|
||||
WHERE u."deletedAt" IS NULL
|
||||
ORDER BY u."lastActiveAt" DESC NULLS LAST
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
statistics: {
|
||||
totalUsers: stats.total_users,
|
||||
adminUsers: stats.admin_users,
|
||||
suspendedUsers: stats.suspended_users,
|
||||
totalTeams: stats.total_teams,
|
||||
oauthClients: stats.oauth_clients,
|
||||
oauthAuthentications: stats.oauth_authentications,
|
||||
},
|
||||
recentActivity: recentActivity.rows,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* auth.config - Get authentication provider configuration
|
||||
*/
|
||||
const getAuthConfig: BaseTool<Record<string, never>> = {
|
||||
name: 'outline_auth_config',
|
||||
description: 'Get authentication provider configuration. Returns enabled authentication methods and their settings.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
// Get authentication providers
|
||||
const providersQuery = await pgClient.query<AuthenticationProvider>(`
|
||||
SELECT
|
||||
ap.id,
|
||||
ap.name,
|
||||
ap.enabled,
|
||||
ap."teamId",
|
||||
ap."createdAt",
|
||||
ap."updatedAt",
|
||||
t.name as "teamName"
|
||||
FROM authentication_providers ap
|
||||
LEFT JOIN teams t ON ap."teamId" = t.id
|
||||
ORDER BY ap.name
|
||||
`);
|
||||
|
||||
// Get team authentication settings
|
||||
const teamsQuery = await pgClient.query(`
|
||||
SELECT
|
||||
id,
|
||||
name,
|
||||
subdomain,
|
||||
domain,
|
||||
"guestSignin",
|
||||
"inviteRequired",
|
||||
"defaultUserRole",
|
||||
"createdAt"
|
||||
FROM teams
|
||||
`);
|
||||
|
||||
// Get OAuth client statistics per team
|
||||
const oauthStatsQuery = await pgClient.query(`
|
||||
SELECT
|
||||
t.id as "teamId",
|
||||
t.name as "teamName",
|
||||
COUNT(oc.id) as "clientCount"
|
||||
FROM teams t
|
||||
LEFT JOIN oauth_clients oc ON oc."teamId" = t.id
|
||||
GROUP BY t.id, t.name
|
||||
`);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
providers: providersQuery.rows,
|
||||
teams: teamsQuery.rows,
|
||||
oauthStatistics: oauthStatsQuery.rows,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all authentication tools
|
||||
export const authTools: BaseTool<any>[] = [getAuthInfo, getAuthConfig];
|
||||
1334
src/tools/collections.ts
Normal file
1334
src/tools/collections.ts
Normal file
File diff suppressed because it is too large
Load Diff
480
src/tools/comments.ts
Normal file
480
src/tools/comments.ts
Normal file
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Comments Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, CommentArgs, GetCommentArgs, CreateCommentArgs, UpdateCommentArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* comments.list - List comments with optional filters
|
||||
*/
|
||||
const listComments: BaseTool<CommentArgs> = {
|
||||
name: 'outline_comments_list',
|
||||
description: 'List comments with optional filtering by document or collection. Supports pagination.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by document ID (UUID)',
|
||||
},
|
||||
collection_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by collection ID (UUID)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results (default: 25, max: 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip (default: 0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
conditions.push(`c."documentId" = $${paramIndex++}`);
|
||||
params.push(args.document_id);
|
||||
}
|
||||
|
||||
if (args.collection_id) {
|
||||
if (!isValidUUID(args.collection_id)) {
|
||||
throw new Error('Invalid collection_id format');
|
||||
}
|
||||
conditions.push(`d."collectionId" = $${paramIndex++}`);
|
||||
params.push(args.collection_id);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
c.id,
|
||||
c.data,
|
||||
c."documentId",
|
||||
c."parentCommentId",
|
||||
c."createdById",
|
||||
c."resolvedById",
|
||||
c."resolvedAt",
|
||||
c."createdAt",
|
||||
c."updatedAt",
|
||||
u.name as "createdByName",
|
||||
u.email as "createdByEmail",
|
||||
ru.name as "resolvedByName",
|
||||
d.title as "documentTitle"
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON c."createdById" = u.id
|
||||
LEFT JOIN users ru ON c."resolvedById" = ru.id
|
||||
LEFT JOIN documents d ON c."documentId" = d.id
|
||||
${whereClause}
|
||||
ORDER BY c."createdAt" DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await pgClient.query(query, params);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total: result.rows.length,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* comments.info - Get detailed information about a specific comment
|
||||
*/
|
||||
const getComment: BaseTool<GetCommentArgs> = {
|
||||
name: 'outline_comments_info',
|
||||
description: 'Get detailed information about a specific comment by ID.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Comment ID (UUID)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid comment ID format');
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
c.id,
|
||||
c.data,
|
||||
c."documentId",
|
||||
c."parentCommentId",
|
||||
c."createdById",
|
||||
c."resolvedById",
|
||||
c."resolvedAt",
|
||||
c."createdAt",
|
||||
c."updatedAt",
|
||||
u.name as "createdByName",
|
||||
u.email as "createdByEmail",
|
||||
ru.name as "resolvedByName",
|
||||
d.title as "documentTitle",
|
||||
d."collectionId"
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON c."createdById" = u.id
|
||||
LEFT JOIN users ru ON c."resolvedById" = ru.id
|
||||
LEFT JOIN documents d ON c."documentId" = d.id
|
||||
WHERE c.id = $1
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Comment not found');
|
||||
}
|
||||
|
||||
// Get replies if this is a parent comment
|
||||
const repliesQuery = `
|
||||
SELECT
|
||||
c.id,
|
||||
c.data,
|
||||
c."createdById",
|
||||
c."createdAt",
|
||||
u.name as "createdByName"
|
||||
FROM comments c
|
||||
LEFT JOIN users u ON c."createdById" = u.id
|
||||
WHERE c."parentCommentId" = $1
|
||||
ORDER BY c."createdAt" ASC
|
||||
`;
|
||||
|
||||
const replies = await pgClient.query(repliesQuery, [args.id]);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
...result.rows[0],
|
||||
replies: replies.rows,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* comments.create - Create a new comment
|
||||
*/
|
||||
const createComment: BaseTool<CreateCommentArgs> = {
|
||||
name: 'outline_comments_create',
|
||||
description: 'Create a new comment on a document. Can be a top-level comment or a reply to another comment.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Document ID (UUID)',
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Comment data (JSON object with content)',
|
||||
},
|
||||
parent_comment_id: {
|
||||
type: 'string',
|
||||
description: 'Parent comment ID for replies (UUID, optional)',
|
||||
},
|
||||
},
|
||||
required: ['document_id', 'data'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
|
||||
if (args.parent_comment_id && !isValidUUID(args.parent_comment_id)) {
|
||||
throw new Error('Invalid parent_comment_id format');
|
||||
}
|
||||
|
||||
// Verify document exists
|
||||
const docCheck = await pgClient.query(
|
||||
'SELECT id FROM documents WHERE id = $1 AND "deletedAt" IS NULL',
|
||||
[args.document_id]
|
||||
);
|
||||
|
||||
if (docCheck.rows.length === 0) {
|
||||
throw new Error('Document not found or deleted');
|
||||
}
|
||||
|
||||
// Verify parent comment exists if provided
|
||||
if (args.parent_comment_id) {
|
||||
const parentCheck = await pgClient.query(
|
||||
'SELECT id FROM comments WHERE id = $1 AND "documentId" = $2',
|
||||
[args.parent_comment_id, args.document_id]
|
||||
);
|
||||
|
||||
if (parentCheck.rows.length === 0) {
|
||||
throw new Error('Parent comment not found or not on the same document');
|
||||
}
|
||||
}
|
||||
|
||||
// Note: In real implementation, createdById should come from authentication context
|
||||
// For now, we'll get the first admin user
|
||||
const userQuery = await pgClient.query(
|
||||
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
||||
);
|
||||
|
||||
if (userQuery.rows.length === 0) {
|
||||
throw new Error('No valid user found to create comment');
|
||||
}
|
||||
|
||||
const createdById = userQuery.rows[0].id;
|
||||
|
||||
const query = `
|
||||
INSERT INTO comments (
|
||||
"documentId",
|
||||
"data",
|
||||
"parentCommentId",
|
||||
"createdById",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
) VALUES ($1, $2, $3, $4, NOW(), NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [
|
||||
args.document_id,
|
||||
JSON.stringify(args.data),
|
||||
args.parent_comment_id || null,
|
||||
createdById,
|
||||
]);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* comments.update - Update an existing comment
|
||||
*/
|
||||
const updateComment: BaseTool<UpdateCommentArgs> = {
|
||||
name: 'outline_comments_update',
|
||||
description: 'Update the content of an existing comment.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Comment ID (UUID)',
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
description: 'Updated comment data (JSON object)',
|
||||
},
|
||||
},
|
||||
required: ['id', 'data'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid comment ID format');
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE comments
|
||||
SET
|
||||
"data" = $1,
|
||||
"updatedAt" = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [JSON.stringify(args.data), args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Comment not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* comments.delete - Delete a comment
|
||||
*/
|
||||
const deleteComment: BaseTool<GetCommentArgs> = {
|
||||
name: 'outline_comments_delete',
|
||||
description: 'Delete a comment. This will also delete all replies to this comment.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Comment ID (UUID)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid comment ID format');
|
||||
}
|
||||
|
||||
// Delete replies first
|
||||
await pgClient.query('DELETE FROM comments WHERE "parentCommentId" = $1', [args.id]);
|
||||
|
||||
// Delete the comment
|
||||
const result = await pgClient.query('DELETE FROM comments WHERE id = $1 RETURNING id', [args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Comment not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: 'Comment deleted successfully',
|
||||
id: result.rows[0].id,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* comments.resolve - Mark a comment as resolved
|
||||
*/
|
||||
const resolveComment: BaseTool<GetCommentArgs> = {
|
||||
name: 'outline_comments_resolve',
|
||||
description: 'Mark a comment as resolved. Can also be used to unresolve a comment by setting resolved to false.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Comment ID (UUID)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid comment ID format');
|
||||
}
|
||||
|
||||
// Get first admin user as resolver
|
||||
const userQuery = await pgClient.query(
|
||||
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
||||
);
|
||||
|
||||
if (userQuery.rows.length === 0) {
|
||||
throw new Error('No valid user found to resolve comment');
|
||||
}
|
||||
|
||||
const resolvedById = userQuery.rows[0].id;
|
||||
|
||||
const query = `
|
||||
UPDATE comments
|
||||
SET
|
||||
"resolvedById" = $1,
|
||||
"resolvedAt" = NOW(),
|
||||
"updatedAt" = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [resolvedById, args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Comment not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all comment tools
|
||||
export const commentsTools: BaseTool<any>[] = [
|
||||
listComments,
|
||||
getComment,
|
||||
createComment,
|
||||
updateComment,
|
||||
deleteComment,
|
||||
resolveComment,
|
||||
];
|
||||
1342
src/tools/documents.ts
Normal file
1342
src/tools/documents.ts
Normal file
File diff suppressed because it is too large
Load Diff
370
src/tools/events.ts
Normal file
370
src/tools/events.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Events Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, EventArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* events.list - List events with optional filters
|
||||
*/
|
||||
const listEvents: BaseTool<EventArgs> = {
|
||||
name: 'outline_events_list',
|
||||
description: 'List system events with optional filtering by name, actor, document, collection, or date range. Useful for audit logs and activity tracking.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Filter by event name (e.g., "documents.create", "users.signin")',
|
||||
},
|
||||
actor_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by actor user ID (UUID)',
|
||||
},
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by document ID (UUID)',
|
||||
},
|
||||
collection_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by collection ID (UUID)',
|
||||
},
|
||||
date_from: {
|
||||
type: 'string',
|
||||
description: 'Filter events from this date (ISO 8601 format)',
|
||||
},
|
||||
date_to: {
|
||||
type: 'string',
|
||||
description: 'Filter events until this date (ISO 8601 format)',
|
||||
},
|
||||
audit_log: {
|
||||
type: 'boolean',
|
||||
description: 'Filter only audit log worthy events (default: false)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results (default: 25, max: 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip (default: 0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (args.name) {
|
||||
conditions.push(`e.name = $${paramIndex++}`);
|
||||
params.push(args.name);
|
||||
}
|
||||
|
||||
if (args.actor_id) {
|
||||
if (!isValidUUID(args.actor_id)) {
|
||||
throw new Error('Invalid actor_id format');
|
||||
}
|
||||
conditions.push(`e."actorId" = $${paramIndex++}`);
|
||||
params.push(args.actor_id);
|
||||
}
|
||||
|
||||
if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
conditions.push(`e."documentId" = $${paramIndex++}`);
|
||||
params.push(args.document_id);
|
||||
}
|
||||
|
||||
if (args.collection_id) {
|
||||
if (!isValidUUID(args.collection_id)) {
|
||||
throw new Error('Invalid collection_id format');
|
||||
}
|
||||
conditions.push(`e."collectionId" = $${paramIndex++}`);
|
||||
params.push(args.collection_id);
|
||||
}
|
||||
|
||||
if (args.date_from) {
|
||||
conditions.push(`e."createdAt" >= $${paramIndex++}`);
|
||||
params.push(args.date_from);
|
||||
}
|
||||
|
||||
if (args.date_to) {
|
||||
conditions.push(`e."createdAt" <= $${paramIndex++}`);
|
||||
params.push(args.date_to);
|
||||
}
|
||||
|
||||
// Audit log filter - common audit events
|
||||
if (args.audit_log) {
|
||||
conditions.push(`e.name IN (
|
||||
'users.create', 'users.update', 'users.delete', 'users.signin',
|
||||
'documents.create', 'documents.update', 'documents.delete', 'documents.publish',
|
||||
'collections.create', 'collections.update', 'collections.delete',
|
||||
'groups.create', 'groups.update', 'groups.delete',
|
||||
'shares.create', 'shares.revoke'
|
||||
)`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
e.id,
|
||||
e.name,
|
||||
e."modelId",
|
||||
e."actorId",
|
||||
e."userId",
|
||||
e."collectionId",
|
||||
e."documentId",
|
||||
e."teamId",
|
||||
e.ip,
|
||||
e.data,
|
||||
e."createdAt",
|
||||
actor.name as "actorName",
|
||||
actor.email as "actorEmail",
|
||||
u.name as "userName",
|
||||
c.name as "collectionName",
|
||||
d.title as "documentTitle"
|
||||
FROM events e
|
||||
LEFT JOIN users actor ON e."actorId" = actor.id
|
||||
LEFT JOIN users u ON e."userId" = u.id
|
||||
LEFT JOIN collections c ON e."collectionId" = c.id
|
||||
LEFT JOIN documents d ON e."documentId" = d.id
|
||||
${whereClause}
|
||||
ORDER BY e."createdAt" DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await pgClient.query(query, params);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total: result.rows.length,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* events.info - Get detailed information about a specific event
|
||||
*/
|
||||
const getEvent: BaseTool<{ id: string }> = {
|
||||
name: 'outline_events_info',
|
||||
description: 'Get detailed information about a specific event by ID, including all associated metadata.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Event ID (UUID)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid event ID format');
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
e.id,
|
||||
e.name,
|
||||
e."modelId",
|
||||
e."actorId",
|
||||
e."userId",
|
||||
e."collectionId",
|
||||
e."documentId",
|
||||
e."teamId",
|
||||
e.ip,
|
||||
e.data,
|
||||
e."createdAt",
|
||||
actor.name as "actorName",
|
||||
actor.email as "actorEmail",
|
||||
actor."isAdmin" as "actorIsAdmin",
|
||||
u.name as "userName",
|
||||
u.email as "userEmail",
|
||||
c.name as "collectionName",
|
||||
d.title as "documentTitle",
|
||||
t.name as "teamName"
|
||||
FROM events e
|
||||
LEFT JOIN users actor ON e."actorId" = actor.id
|
||||
LEFT JOIN users u ON e."userId" = u.id
|
||||
LEFT JOIN collections c ON e."collectionId" = c.id
|
||||
LEFT JOIN documents d ON e."documentId" = d.id
|
||||
LEFT JOIN teams t ON e."teamId" = t.id
|
||||
WHERE e.id = $1
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Event not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* events.stats - Get event statistics and summaries
|
||||
*/
|
||||
const getEventStats: BaseTool<EventArgs> = {
|
||||
name: 'outline_events_stats',
|
||||
description: 'Get statistical analysis of events. Provides event counts by type, top actors, and activity trends.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
date_from: {
|
||||
type: 'string',
|
||||
description: 'Statistics from this date (ISO 8601 format)',
|
||||
},
|
||||
date_to: {
|
||||
type: 'string',
|
||||
description: 'Statistics until this date (ISO 8601 format)',
|
||||
},
|
||||
collection_id: {
|
||||
type: 'string',
|
||||
description: 'Filter statistics by collection ID (UUID)',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (args.date_from) {
|
||||
conditions.push(`e."createdAt" >= $${paramIndex++}`);
|
||||
params.push(args.date_from);
|
||||
}
|
||||
|
||||
if (args.date_to) {
|
||||
conditions.push(`e."createdAt" <= $${paramIndex++}`);
|
||||
params.push(args.date_to);
|
||||
}
|
||||
|
||||
if (args.collection_id) {
|
||||
if (!isValidUUID(args.collection_id)) {
|
||||
throw new Error('Invalid collection_id format');
|
||||
}
|
||||
conditions.push(`e."collectionId" = $${paramIndex++}`);
|
||||
params.push(args.collection_id);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
// Event counts by type
|
||||
const eventsByTypeQuery = await pgClient.query(
|
||||
`SELECT
|
||||
e.name,
|
||||
COUNT(*) as count
|
||||
FROM events e
|
||||
${whereClause}
|
||||
GROUP BY e.name
|
||||
ORDER BY count DESC
|
||||
LIMIT 20`,
|
||||
params
|
||||
);
|
||||
|
||||
// Top actors
|
||||
const topActorsQuery = await pgClient.query(
|
||||
`SELECT
|
||||
e."actorId",
|
||||
u.name as "actorName",
|
||||
u.email as "actorEmail",
|
||||
COUNT(*) as "eventCount"
|
||||
FROM events e
|
||||
LEFT JOIN users u ON e."actorId" = u.id
|
||||
${whereClause}
|
||||
GROUP BY e."actorId", u.name, u.email
|
||||
ORDER BY "eventCount" DESC
|
||||
LIMIT 10`,
|
||||
params
|
||||
);
|
||||
|
||||
// Activity by day (last 30 days or filtered range)
|
||||
const activityByDayQuery = await pgClient.query(
|
||||
`SELECT
|
||||
DATE(e."createdAt") as date,
|
||||
COUNT(*) as "eventCount"
|
||||
FROM events e
|
||||
${whereClause}
|
||||
GROUP BY DATE(e."createdAt")
|
||||
ORDER BY date DESC
|
||||
LIMIT 30`,
|
||||
params
|
||||
);
|
||||
|
||||
// Total statistics
|
||||
const totalsQuery = await pgClient.query(
|
||||
`SELECT
|
||||
COUNT(*) as "totalEvents",
|
||||
COUNT(DISTINCT e."actorId") as "uniqueActors",
|
||||
COUNT(DISTINCT e."documentId") as "affectedDocuments",
|
||||
COUNT(DISTINCT e."collectionId") as "affectedCollections"
|
||||
FROM events e
|
||||
${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
totals: totalsQuery.rows[0],
|
||||
eventsByType: eventsByTypeQuery.rows,
|
||||
topActors: topActorsQuery.rows,
|
||||
activityByDay: activityByDayQuery.rows,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all event tools
|
||||
export const eventsTools: BaseTool<any>[] = [
|
||||
listEvents,
|
||||
getEvent,
|
||||
getEventStats,
|
||||
];
|
||||
303
src/tools/file-operations.ts
Normal file
303
src/tools/file-operations.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - File Operations Tools
|
||||
* Handles background file operations (import/export tracking)
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import {
|
||||
BaseTool,
|
||||
ToolResponse,
|
||||
FileOperationArgs,
|
||||
GetFileOperationArgs,
|
||||
} from '../types/tools.js';
|
||||
import { FileOperation } from '../types/db.js';
|
||||
|
||||
/**
|
||||
* file_operations.list - List background file operations
|
||||
*/
|
||||
const listFileOperations: BaseTool<FileOperationArgs> = {
|
||||
name: 'outline_file_operations_list',
|
||||
description: 'List background file operations (imports/exports) with optional filtering by type. Returns operation status, progress, and download URLs for completed exports.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['import', 'export'],
|
||||
description: 'Filter by operation type',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return',
|
||||
default: 25,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { type, limit = 25, offset = 0 } = args;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
fo.id,
|
||||
fo.type,
|
||||
fo.state,
|
||||
fo.format,
|
||||
fo.size,
|
||||
fo.key,
|
||||
fo.url,
|
||||
fo.error,
|
||||
fo."collectionId",
|
||||
fo."userId",
|
||||
fo."teamId",
|
||||
fo."createdAt",
|
||||
fo."updatedAt",
|
||||
u.name as "userName",
|
||||
u.email as "userEmail",
|
||||
c.name as "collectionName"
|
||||
FROM file_operations fo
|
||||
LEFT JOIN users u ON fo."userId" = u.id
|
||||
LEFT JOIN collections c ON fo."collectionId" = c.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (type) {
|
||||
query += ` AND fo.type = $${paramIndex}`;
|
||||
params.push(type);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
query += ` ORDER BY fo."createdAt" DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await pgClient.query(query, params);
|
||||
|
||||
const operations = result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
state: row.state,
|
||||
format: row.format,
|
||||
size: row.size,
|
||||
url: row.url,
|
||||
error: row.error,
|
||||
collectionId: row.collectionId,
|
||||
collectionName: row.collectionName,
|
||||
userId: row.userId,
|
||||
userName: row.userName,
|
||||
userEmail: row.userEmail,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: operations,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total: result.rowCount || 0,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* file_operations.info - Get file operation details
|
||||
*/
|
||||
const getFileOperation: BaseTool<GetFileOperationArgs> = {
|
||||
name: 'outline_file_operations_info',
|
||||
description: 'Get detailed information about a specific file operation by ID. Use this to track job status and get download URLs for completed exports.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'File operation UUID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { id } = args;
|
||||
|
||||
const result = await pgClient.query<FileOperation>(
|
||||
`
|
||||
SELECT
|
||||
fo.*,
|
||||
u.name as "userName",
|
||||
u.email as "userEmail",
|
||||
c.name as "collectionName"
|
||||
FROM file_operations fo
|
||||
LEFT JOIN users u ON fo."userId" = u.id
|
||||
LEFT JOIN collections c ON fo."collectionId" = c.id
|
||||
WHERE fo.id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`File operation not found: ${id}`);
|
||||
}
|
||||
|
||||
const operation = result.rows[0];
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: operation,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* file_operations.redirect - Get download URL for completed file operation
|
||||
*/
|
||||
const redirectFileOperation: BaseTool<GetFileOperationArgs> = {
|
||||
name: 'outline_file_operations_redirect',
|
||||
description: 'Get the download URL for a completed file operation. Returns the URL field from the operation if state is "complete".',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'File operation UUID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { id } = args;
|
||||
|
||||
const result = await pgClient.query<FileOperation>(
|
||||
`
|
||||
SELECT id, state, url, type, format
|
||||
FROM file_operations
|
||||
WHERE id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`File operation not found: ${id}`);
|
||||
}
|
||||
|
||||
const operation = result.rows[0];
|
||||
|
||||
if (operation.state !== 'complete') {
|
||||
throw new Error(
|
||||
`File operation not complete. Current state: ${operation.state}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!operation.url) {
|
||||
throw new Error('Download URL not available for this operation');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
id: operation.id,
|
||||
url: operation.url,
|
||||
type: operation.type,
|
||||
format: operation.format,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* file_operations.delete - Remove file operation record
|
||||
*/
|
||||
const deleteFileOperation: BaseTool<GetFileOperationArgs> = {
|
||||
name: 'outline_file_operations_delete',
|
||||
description: 'Delete a file operation record from the database. This removes the tracking record but does not delete the actual file if it was uploaded to storage.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'File operation UUID to delete',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { id } = args;
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
DELETE FROM file_operations
|
||||
WHERE id = $1
|
||||
RETURNING id, type, state
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`File operation not found: ${id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
success: true,
|
||||
deleted: result.rows[0],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all file operation tools
|
||||
export const fileOperationsTools: BaseTool<any>[] = [
|
||||
listFileOperations,
|
||||
getFileOperation,
|
||||
redirectFileOperation,
|
||||
deleteFileOperation,
|
||||
];
|
||||
564
src/tools/groups.ts
Normal file
564
src/tools/groups.ts
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Groups Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, GroupArgs, GetGroupArgs, CreateGroupArgs, UpdateGroupArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* groups.list - List all groups
|
||||
*/
|
||||
const listGroups: BaseTool<GroupArgs> = {
|
||||
name: 'outline_list_groups',
|
||||
description: 'List all groups with optional search query. Supports pagination and returns group details including member counts.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query to filter groups by name',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return (max 100)',
|
||||
default: 25,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip for pagination',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const query = args.query ? sanitizeInput(args.query) : undefined;
|
||||
|
||||
let whereConditions = ['g."deletedAt" IS NULL'];
|
||||
const queryParams: any[] = [limit, offset];
|
||||
let paramIndex = 3;
|
||||
|
||||
if (query) {
|
||||
whereConditions.push(`LOWER(g.name) LIKE LOWER($${paramIndex})`);
|
||||
queryParams.push(`%${query}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g."teamId",
|
||||
g."createdById",
|
||||
g."createdAt",
|
||||
g."updatedAt",
|
||||
t.name as "teamName",
|
||||
u.name as "createdByName",
|
||||
(SELECT COUNT(*) FROM group_users WHERE "groupId" = g.id AND "deletedAt" IS NULL) as "memberCount",
|
||||
(SELECT COUNT(*) FROM groups WHERE ${whereConditions.join(' AND ')}) as total
|
||||
FROM groups g
|
||||
LEFT JOIN teams t ON g."teamId" = t.id
|
||||
LEFT JOIN users u ON g."createdById" = u.id
|
||||
${whereClause}
|
||||
ORDER BY g."createdAt" DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
groups: result.rows,
|
||||
total: result.rows.length > 0 ? parseInt(result.rows[0].total) : 0,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* groups.info - Get group details by ID
|
||||
*/
|
||||
const getGroup: BaseTool<GetGroupArgs> = {
|
||||
name: 'outline_get_group',
|
||||
description: 'Get detailed information about a specific group by its ID. Returns group details and member statistics.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Group UUID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
SELECT
|
||||
g.id,
|
||||
g.name,
|
||||
g."teamId",
|
||||
g."createdById",
|
||||
g."createdAt",
|
||||
g."updatedAt",
|
||||
t.name as "teamName",
|
||||
u.name as "createdByName",
|
||||
(SELECT COUNT(*) FROM group_users WHERE "groupId" = g.id AND "deletedAt" IS NULL) as "memberCount"
|
||||
FROM groups g
|
||||
LEFT JOIN teams t ON g."teamId" = t.id
|
||||
LEFT JOIN users u ON g."createdById" = u.id
|
||||
WHERE g.id = $1 AND g."deletedAt" IS NULL
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* groups.create - Create new group
|
||||
*/
|
||||
const createGroup: BaseTool<CreateGroupArgs> = {
|
||||
name: 'outline_create_group',
|
||||
description: 'Create a new group with the specified name. Groups can be used to organize users and manage permissions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Name of the group',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const name = sanitizeInput(args.name);
|
||||
|
||||
// Get team ID (assuming first team, adjust as needed)
|
||||
const teamResult = await pgClient.query(`SELECT id FROM teams LIMIT 1`);
|
||||
if (teamResult.rows.length === 0) {
|
||||
throw new Error('No team found');
|
||||
}
|
||||
const teamId = teamResult.rows[0].id;
|
||||
|
||||
// Get first admin user as creator (adjust as needed)
|
||||
const userResult = await pgClient.query(
|
||||
`SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1`
|
||||
);
|
||||
if (userResult.rows.length === 0) {
|
||||
throw new Error('No admin user found');
|
||||
}
|
||||
const createdById = userResult.rows[0].id;
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
INSERT INTO groups (
|
||||
id, name, "teamId", "createdById",
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
VALUES (
|
||||
gen_random_uuid(), $1, $2, $3,
|
||||
NOW(), NOW()
|
||||
)
|
||||
RETURNING *
|
||||
`,
|
||||
[name, teamId, createdById]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'Group created successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* groups.update - Update group details
|
||||
*/
|
||||
const updateGroup: BaseTool<UpdateGroupArgs> = {
|
||||
name: 'outline_update_group',
|
||||
description: 'Update the name of an existing group.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Group UUID',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'New name for the group',
|
||||
},
|
||||
},
|
||||
required: ['id', 'name'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const name = sanitizeInput(args.name);
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE groups
|
||||
SET name = $1, "updatedAt" = NOW()
|
||||
WHERE id = $2 AND "deletedAt" IS NULL
|
||||
RETURNING *
|
||||
`,
|
||||
[name, args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'Group updated successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* groups.delete - Soft delete group
|
||||
*/
|
||||
const deleteGroup: BaseTool<GetGroupArgs> = {
|
||||
name: 'outline_delete_group',
|
||||
description: 'Soft delete a group. This marks the group as deleted but preserves the data for audit purposes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Group UUID to delete',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE groups
|
||||
SET "deletedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, name
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Group not found or already deleted');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'Group deleted successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* groups.memberships - List group members
|
||||
*/
|
||||
const listGroupMembers: BaseTool<GetGroupArgs> = {
|
||||
name: 'outline_list_group_members',
|
||||
description: 'List all members of a specific group. Returns user details for each group member.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Group UUID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
SELECT
|
||||
gu.id as "membershipId",
|
||||
gu."userId",
|
||||
gu."groupId",
|
||||
gu."createdById",
|
||||
gu."createdAt",
|
||||
u.name as "userName",
|
||||
u.email as "userEmail",
|
||||
u."isAdmin" as "userIsAdmin",
|
||||
creator.name as "addedByName"
|
||||
FROM group_users gu
|
||||
JOIN users u ON gu."userId" = u.id
|
||||
LEFT JOIN users creator ON gu."createdById" = creator.id
|
||||
WHERE gu."groupId" = $1 AND gu."deletedAt" IS NULL AND u."deletedAt" IS NULL
|
||||
ORDER BY gu."createdAt" DESC
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
members: result.rows,
|
||||
total: result.rows.length,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* groups.add_user - Add user to group
|
||||
*/
|
||||
const addUserToGroup: BaseTool<{ id: string; user_id: string }> = {
|
||||
name: 'outline_add_user_to_group',
|
||||
description: 'Add a user to a group. Creates a group membership relationship.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Group UUID',
|
||||
},
|
||||
user_id: {
|
||||
type: 'string',
|
||||
description: 'User UUID to add to the group',
|
||||
},
|
||||
},
|
||||
required: ['id', 'user_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||
}
|
||||
if (!isValidUUID(args.user_id)) {
|
||||
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
// Check if group exists
|
||||
const groupCheck = await pgClient.query(
|
||||
`SELECT id FROM groups WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||
[args.id]
|
||||
);
|
||||
if (groupCheck.rows.length === 0) {
|
||||
throw new Error('Group not found');
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const userCheck = await pgClient.query(
|
||||
`SELECT id FROM users WHERE id = $1 AND "deletedAt" IS NULL`,
|
||||
[args.user_id]
|
||||
);
|
||||
if (userCheck.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
// Check if user is already in group
|
||||
const existingMembership = await pgClient.query(
|
||||
`SELECT id FROM group_users WHERE "groupId" = $1 AND "userId" = $2 AND "deletedAt" IS NULL`,
|
||||
[args.id, args.user_id]
|
||||
);
|
||||
if (existingMembership.rows.length > 0) {
|
||||
throw new Error('User is already a member of this group');
|
||||
}
|
||||
|
||||
// Get first admin user as creator (adjust as needed)
|
||||
const creatorResult = await pgClient.query(
|
||||
`SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1`
|
||||
);
|
||||
const createdById = creatorResult.rows.length > 0 ? creatorResult.rows[0].id : args.user_id;
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
INSERT INTO group_users (
|
||||
id, "userId", "groupId", "createdById",
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
VALUES (
|
||||
gen_random_uuid(), $1, $2, $3,
|
||||
NOW(), NOW()
|
||||
)
|
||||
RETURNING *
|
||||
`,
|
||||
[args.user_id, args.id, createdById]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'User added to group successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* groups.remove_user - Remove user from group
|
||||
*/
|
||||
const removeUserFromGroup: BaseTool<{ id: string; user_id: string }> = {
|
||||
name: 'outline_remove_user_from_group',
|
||||
description: 'Remove a user from a group. Soft deletes the group membership.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Group UUID',
|
||||
},
|
||||
user_id: {
|
||||
type: 'string',
|
||||
description: 'User UUID to remove from the group',
|
||||
},
|
||||
},
|
||||
required: ['id', 'user_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid group ID format. Must be a valid UUID.');
|
||||
}
|
||||
if (!isValidUUID(args.user_id)) {
|
||||
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE group_users
|
||||
SET "deletedAt" = NOW()
|
||||
WHERE "groupId" = $1 AND "userId" = $2 AND "deletedAt" IS NULL
|
||||
RETURNING id, "userId", "groupId"
|
||||
`,
|
||||
[args.id, args.user_id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User is not a member of this group');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'User removed from group successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all group tools
|
||||
export const groupsTools: BaseTool<any>[] = [
|
||||
listGroups,
|
||||
getGroup,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
listGroupMembers,
|
||||
addUserToGroup,
|
||||
removeUserFromGroup,
|
||||
];
|
||||
41
src/tools/index.ts
Normal file
41
src/tools/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Tools Index
|
||||
* Central export for all MCP tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
// Document Tools - Core document management (to be implemented)
|
||||
export { documentsTools } from './documents.js';
|
||||
|
||||
// Collection Tools - Collection management (to be implemented)
|
||||
export { collectionsTools } from './collections.js';
|
||||
|
||||
// User Tools - User management (to be implemented)
|
||||
export { usersTools } from './users.js';
|
||||
|
||||
// Group Tools - Group and team management (to be implemented)
|
||||
export { groupsTools } from './groups.js';
|
||||
|
||||
// Comment Tools - Comment management (to be implemented)
|
||||
export { commentsTools } from './comments.js';
|
||||
|
||||
// Share Tools - Document sharing and public links (to be implemented)
|
||||
export { sharesTools } from './shares.js';
|
||||
|
||||
// Revision Tools - Document version history (to be implemented)
|
||||
export { revisionsTools } from './revisions.js';
|
||||
|
||||
// Event Tools - Audit log and activity tracking (to be implemented)
|
||||
export { eventsTools } from './events.js';
|
||||
|
||||
// Attachment Tools - File attachments (to be implemented)
|
||||
export { attachmentsTools } from './attachments.js';
|
||||
|
||||
// File Operation Tools - Import/Export operations
|
||||
export { fileOperationsTools } from './file-operations.js';
|
||||
|
||||
// OAuth Tools - OAuth client management
|
||||
export { oauthTools } from './oauth.js';
|
||||
|
||||
// Auth Tools - Authentication and authorization
|
||||
export { authTools } from './auth.js';
|
||||
546
src/tools/oauth.ts
Normal file
546
src/tools/oauth.ts
Normal file
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - OAuth Tools
|
||||
* Manages OAuth applications and user authentications
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import {
|
||||
BaseTool,
|
||||
ToolResponse,
|
||||
OAuthClientArgs,
|
||||
GetOAuthClientArgs,
|
||||
CreateOAuthClientArgs,
|
||||
UpdateOAuthClientArgs,
|
||||
PaginationArgs,
|
||||
} from '../types/tools.js';
|
||||
|
||||
interface OAuthClient {
|
||||
id: string;
|
||||
name: string;
|
||||
secret: string;
|
||||
redirectUris: string[];
|
||||
description?: string;
|
||||
teamId: string;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface OAuthAuthentication {
|
||||
id: string;
|
||||
providerId: string;
|
||||
userId: string;
|
||||
teamId: string;
|
||||
scopes: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* oauth_clients.list - List OAuth applications
|
||||
*/
|
||||
const listOAuthClients: BaseTool<OAuthClientArgs> = {
|
||||
name: 'outline_oauth_clients_list',
|
||||
description: 'List all registered OAuth applications/clients. Returns client details including name, redirect URIs, and creation info.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return',
|
||||
default: 25,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit = 25, offset = 0 } = args;
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
SELECT
|
||||
oc.*,
|
||||
u.name as "createdByName",
|
||||
u.email as "createdByEmail"
|
||||
FROM oauth_clients oc
|
||||
LEFT JOIN users u ON oc."createdById" = u.id
|
||||
ORDER BY oc."createdAt" DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
const clients = result.rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
redirectUris: row.redirectUris,
|
||||
description: row.description,
|
||||
teamId: row.teamId,
|
||||
createdById: row.createdById,
|
||||
createdByName: row.createdByName,
|
||||
createdByEmail: row.createdByEmail,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
// Secret omitted for security
|
||||
}));
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: clients,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total: result.rowCount || 0,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* oauth_clients.info - Get OAuth client details
|
||||
*/
|
||||
const getOAuthClient: BaseTool<GetOAuthClientArgs> = {
|
||||
name: 'outline_oauth_clients_info',
|
||||
description: 'Get detailed information about a specific OAuth client/application by ID.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'OAuth client UUID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { id } = args;
|
||||
|
||||
const result = await pgClient.query<OAuthClient>(
|
||||
`
|
||||
SELECT
|
||||
oc.*,
|
||||
u.name as "createdByName",
|
||||
u.email as "createdByEmail"
|
||||
FROM oauth_clients oc
|
||||
LEFT JOIN users u ON oc."createdById" = u.id
|
||||
WHERE oc.id = $1
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`OAuth client not found: ${id}`);
|
||||
}
|
||||
|
||||
const client = result.rows[0];
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: client,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* oauth_clients.create - Register new OAuth application
|
||||
*/
|
||||
const createOAuthClient: BaseTool<CreateOAuthClientArgs> = {
|
||||
name: 'outline_oauth_clients_create',
|
||||
description: 'Register a new OAuth application/client. Generates client credentials for OAuth flow integration.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Application name',
|
||||
},
|
||||
redirect_uris: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of allowed redirect URIs for OAuth flow',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Optional application description',
|
||||
},
|
||||
},
|
||||
required: ['name', 'redirect_uris'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { name, redirect_uris, description } = args;
|
||||
|
||||
// Generate random client secret (in production, use crypto.randomBytes)
|
||||
const secret = `sk_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
INSERT INTO oauth_clients (name, secret, "redirectUris", description)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`,
|
||||
[name, secret, JSON.stringify(redirect_uris), description]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'OAuth client created successfully. Store the secret securely - it will not be shown again.',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* oauth_clients.update - Modify OAuth application settings
|
||||
*/
|
||||
const updateOAuthClient: BaseTool<UpdateOAuthClientArgs> = {
|
||||
name: 'outline_oauth_clients_update',
|
||||
description: 'Update OAuth client/application settings such as name, redirect URIs, or description.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'OAuth client UUID',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'New application name',
|
||||
},
|
||||
redirect_uris: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Updated list of allowed redirect URIs',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'Updated description',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { id, name, redirect_uris, description } = args;
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (name !== undefined) {
|
||||
updates.push(`name = $${paramIndex}`);
|
||||
values.push(name);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (redirect_uris !== undefined) {
|
||||
updates.push(`"redirectUris" = $${paramIndex}`);
|
||||
values.push(JSON.stringify(redirect_uris));
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (description !== undefined) {
|
||||
updates.push(`description = $${paramIndex}`);
|
||||
values.push(description);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
updates.push(`"updatedAt" = NOW()`);
|
||||
values.push(id);
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE oauth_clients
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *
|
||||
`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`OAuth client not found: ${id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* oauth_clients.rotate_secret - Generate new client secret
|
||||
*/
|
||||
const rotateOAuthClientSecret: BaseTool<GetOAuthClientArgs> = {
|
||||
name: 'outline_oauth_clients_rotate_secret',
|
||||
description: 'Generate a new client secret for an OAuth application. The old secret will be invalidated.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'OAuth client UUID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { id } = args;
|
||||
|
||||
const newSecret = `sk_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`;
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE oauth_clients
|
||||
SET secret = $1, "updatedAt" = NOW()
|
||||
WHERE id = $2
|
||||
RETURNING id, name, secret, "updatedAt"
|
||||
`,
|
||||
[newSecret, id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`OAuth client not found: ${id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'Secret rotated successfully. Update your application configuration with the new secret.',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* oauth_clients.delete - Remove OAuth application
|
||||
*/
|
||||
const deleteOAuthClient: BaseTool<GetOAuthClientArgs> = {
|
||||
name: 'outline_oauth_clients_delete',
|
||||
description: 'Delete an OAuth client/application. This will revoke all active authentications using this client.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'OAuth client UUID to delete',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { id } = args;
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
DELETE FROM oauth_clients
|
||||
WHERE id = $1
|
||||
RETURNING id, name
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`OAuth client not found: ${id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
success: true,
|
||||
deleted: result.rows[0],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* oauth_authentications.list - List user OAuth authentications
|
||||
*/
|
||||
const listOAuthAuthentications: BaseTool<PaginationArgs> = {
|
||||
name: 'outline_oauth_authentications_list',
|
||||
description: 'List all OAuth authentications (user authorizations). Shows which users have granted access to which providers.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return',
|
||||
default: 25,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit = 25, offset = 0 } = args;
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
SELECT
|
||||
oa.*,
|
||||
u.name as "userName",
|
||||
u.email as "userEmail"
|
||||
FROM oauth_authentications oa
|
||||
LEFT JOIN users u ON oa."userId" = u.id
|
||||
ORDER BY oa."createdAt" DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows,
|
||||
pagination: {
|
||||
offset,
|
||||
limit,
|
||||
total: result.rowCount || 0,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* oauth_authentications.delete - Revoke OAuth authentication
|
||||
*/
|
||||
const deleteOAuthAuthentication: BaseTool<{ id: string }> = {
|
||||
name: 'outline_oauth_authentications_delete',
|
||||
description: 'Revoke an OAuth authentication. This disconnects the user from the OAuth provider.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'OAuth authentication UUID to revoke',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { id } = args;
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
DELETE FROM oauth_authentications
|
||||
WHERE id = $1
|
||||
RETURNING id, "providerId", "userId"
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error(`OAuth authentication not found: ${id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
success: true,
|
||||
revoked: result.rows[0],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all OAuth tools
|
||||
export const oauthTools: BaseTool<any>[] = [
|
||||
listOAuthClients,
|
||||
getOAuthClient,
|
||||
createOAuthClient,
|
||||
updateOAuthClient,
|
||||
rotateOAuthClientSecret,
|
||||
deleteOAuthClient,
|
||||
listOAuthAuthentications,
|
||||
deleteOAuthAuthentication,
|
||||
];
|
||||
335
src/tools/revisions.ts
Normal file
335
src/tools/revisions.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Revisions Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, RevisionArgs, GetRevisionArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* revisions.list - List document revisions
|
||||
*/
|
||||
const listRevisions: BaseTool<RevisionArgs> = {
|
||||
name: 'outline_revisions_list',
|
||||
description: 'List all revisions for a specific document. Revisions are ordered from newest to oldest.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Document ID (UUID)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results (default: 25, max: 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip (default: 0)',
|
||||
},
|
||||
},
|
||||
required: ['document_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
|
||||
// Verify document exists
|
||||
const docCheck = await pgClient.query(
|
||||
'SELECT id, title FROM documents WHERE id = $1',
|
||||
[args.document_id]
|
||||
);
|
||||
|
||||
if (docCheck.rows.length === 0) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
r.id,
|
||||
r.version,
|
||||
r."editorVersion",
|
||||
r.title,
|
||||
r.emoji,
|
||||
r."documentId",
|
||||
r."userId",
|
||||
r."createdAt",
|
||||
u.name as "createdByName",
|
||||
u.email as "createdByEmail",
|
||||
LENGTH(r.text) as "textLength"
|
||||
FROM revisions r
|
||||
LEFT JOIN users u ON r."userId" = u.id
|
||||
WHERE r."documentId" = $1
|
||||
ORDER BY r.version DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [args.document_id, limit, offset]);
|
||||
|
||||
// Get total count
|
||||
const countQuery = await pgClient.query(
|
||||
'SELECT COUNT(*) as total FROM revisions WHERE "documentId" = $1',
|
||||
[args.document_id]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows,
|
||||
document: docCheck.rows[0],
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total: parseInt(countQuery.rows[0].total),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* revisions.info - Get detailed information about a specific revision
|
||||
*/
|
||||
const getRevision: BaseTool<GetRevisionArgs> = {
|
||||
name: 'outline_revisions_info',
|
||||
description: 'Get detailed information about a specific revision, including full text content.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Revision ID (UUID)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid revision ID format');
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
r.id,
|
||||
r.version,
|
||||
r."editorVersion",
|
||||
r.title,
|
||||
r.text,
|
||||
r.emoji,
|
||||
r."documentId",
|
||||
r."userId",
|
||||
r."createdAt",
|
||||
u.name as "createdByName",
|
||||
u.email as "createdByEmail",
|
||||
d.title as "currentDocumentTitle"
|
||||
FROM revisions r
|
||||
LEFT JOIN users u ON r."userId" = u.id
|
||||
LEFT JOIN documents d ON r."documentId" = d.id
|
||||
WHERE r.id = $1
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Revision not found');
|
||||
}
|
||||
|
||||
// Get revision statistics
|
||||
const statsQuery = await pgClient.query(
|
||||
`SELECT
|
||||
COUNT(*) as "totalRevisions",
|
||||
MIN(version) as "firstVersion",
|
||||
MAX(version) as "latestVersion"
|
||||
FROM revisions
|
||||
WHERE "documentId" = $1`,
|
||||
[result.rows[0].documentId]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
statistics: statsQuery.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* revisions.compare - Compare two revisions or a revision with current document
|
||||
*/
|
||||
const compareRevisions: BaseTool<{ id: string; compare_to?: string }> = {
|
||||
name: 'outline_revisions_compare',
|
||||
description: 'Compare two document revisions or compare a revision with the current document version. Returns both versions for comparison.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'First revision ID (UUID)',
|
||||
},
|
||||
compare_to: {
|
||||
type: 'string',
|
||||
description: 'Second revision ID to compare with (UUID). If not provided, compares with current document.',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid revision ID format');
|
||||
}
|
||||
|
||||
if (args.compare_to && !isValidUUID(args.compare_to)) {
|
||||
throw new Error('Invalid compare_to revision ID format');
|
||||
}
|
||||
|
||||
// Get first revision
|
||||
const revision1Query = await pgClient.query(
|
||||
`SELECT
|
||||
r.id,
|
||||
r.version,
|
||||
r.title,
|
||||
r.text,
|
||||
r.emoji,
|
||||
r."documentId",
|
||||
r."createdAt",
|
||||
u.name as "createdByName"
|
||||
FROM revisions r
|
||||
LEFT JOIN users u ON r."userId" = u.id
|
||||
WHERE r.id = $1`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (revision1Query.rows.length === 0) {
|
||||
throw new Error('Revision not found');
|
||||
}
|
||||
|
||||
const revision1 = revision1Query.rows[0];
|
||||
let revision2;
|
||||
|
||||
if (args.compare_to) {
|
||||
// Compare with another revision
|
||||
const revision2Query = await pgClient.query(
|
||||
`SELECT
|
||||
r.id,
|
||||
r.version,
|
||||
r.title,
|
||||
r.text,
|
||||
r.emoji,
|
||||
r."documentId",
|
||||
r."createdAt",
|
||||
u.name as "createdByName"
|
||||
FROM revisions r
|
||||
LEFT JOIN users u ON r."userId" = u.id
|
||||
WHERE r.id = $1`,
|
||||
[args.compare_to]
|
||||
);
|
||||
|
||||
if (revision2Query.rows.length === 0) {
|
||||
throw new Error('Comparison revision not found');
|
||||
}
|
||||
|
||||
revision2 = revision2Query.rows[0];
|
||||
|
||||
// Verify both revisions are from the same document
|
||||
if (revision1.documentId !== revision2.documentId) {
|
||||
throw new Error('Revisions are from different documents');
|
||||
}
|
||||
} else {
|
||||
// Compare with current document
|
||||
const currentDocQuery = await pgClient.query(
|
||||
`SELECT
|
||||
d.id,
|
||||
d.title,
|
||||
d.text,
|
||||
d.emoji,
|
||||
d."updatedAt" as "createdAt",
|
||||
u.name as "createdByName"
|
||||
FROM documents d
|
||||
LEFT JOIN users u ON d."updatedById" = u.id
|
||||
WHERE d.id = $1`,
|
||||
[revision1.documentId]
|
||||
);
|
||||
|
||||
if (currentDocQuery.rows.length === 0) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
revision2 = {
|
||||
...currentDocQuery.rows[0],
|
||||
version: 'current',
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate basic diff statistics
|
||||
const textLengthDiff = revision2.text.length - revision1.text.length;
|
||||
const titleChanged = revision1.title !== revision2.title;
|
||||
const emojiChanged = revision1.emoji !== revision2.emoji;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
revision1: {
|
||||
id: revision1.id,
|
||||
version: revision1.version,
|
||||
title: revision1.title,
|
||||
text: revision1.text,
|
||||
emoji: revision1.emoji,
|
||||
createdAt: revision1.createdAt,
|
||||
createdByName: revision1.createdByName,
|
||||
},
|
||||
revision2: {
|
||||
id: revision2.id,
|
||||
version: revision2.version,
|
||||
title: revision2.title,
|
||||
text: revision2.text,
|
||||
emoji: revision2.emoji,
|
||||
createdAt: revision2.createdAt,
|
||||
createdByName: revision2.createdByName,
|
||||
},
|
||||
comparison: {
|
||||
titleChanged,
|
||||
emojiChanged,
|
||||
textLengthDiff,
|
||||
textLengthDiffPercent: ((textLengthDiff / revision1.text.length) * 100).toFixed(2) + '%',
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all revision tools
|
||||
export const revisionsTools: BaseTool<any>[] = [
|
||||
listRevisions,
|
||||
getRevision,
|
||||
compareRevisions,
|
||||
];
|
||||
470
src/tools/shares.ts
Normal file
470
src/tools/shares.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Shares Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, ShareArgs, GetShareArgs, CreateShareArgs, UpdateShareArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID, isValidUrlId } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* shares.list - List document shares with optional filters
|
||||
*/
|
||||
const listShares: BaseTool<ShareArgs> = {
|
||||
name: 'outline_shares_list',
|
||||
description: 'List document shares with optional filtering. Supports pagination.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Filter by document ID (UUID)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results (default: 25, max: 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip (default: 0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const conditions: string[] = ['s."revokedAt" IS NULL'];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
conditions.push(`s."documentId" = $${paramIndex++}`);
|
||||
params.push(args.document_id);
|
||||
}
|
||||
|
||||
const whereClause = `WHERE ${conditions.join(' AND ')}`;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
s.id,
|
||||
s."urlId",
|
||||
s."documentId",
|
||||
s."userId",
|
||||
s."teamId",
|
||||
s."includeChildDocuments",
|
||||
s.published,
|
||||
s.domain,
|
||||
s."lastAccessedAt",
|
||||
s.views,
|
||||
s."createdAt",
|
||||
s."updatedAt",
|
||||
d.title as "documentTitle",
|
||||
u.name as "createdByName",
|
||||
u.email as "createdByEmail"
|
||||
FROM shares s
|
||||
LEFT JOIN documents d ON s."documentId" = d.id
|
||||
LEFT JOIN users u ON s."userId" = u.id
|
||||
${whereClause}
|
||||
ORDER BY s."createdAt" DESC
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex}
|
||||
`;
|
||||
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await pgClient.query(query, params);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows,
|
||||
pagination: {
|
||||
limit,
|
||||
offset,
|
||||
total: result.rows.length,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* shares.info - Get detailed information about a specific share
|
||||
*/
|
||||
const getShare: BaseTool<GetShareArgs> = {
|
||||
name: 'outline_shares_info',
|
||||
description: 'Get detailed information about a specific share by ID or document ID.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Share ID (UUID)',
|
||||
},
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Document ID to find share for (UUID)',
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
let query: string;
|
||||
let params: string[];
|
||||
|
||||
if (args.id) {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid share ID format');
|
||||
}
|
||||
query = `
|
||||
SELECT
|
||||
s.id,
|
||||
s."urlId",
|
||||
s."documentId",
|
||||
s."userId",
|
||||
s."teamId",
|
||||
s."includeChildDocuments",
|
||||
s.published,
|
||||
s.domain,
|
||||
s."lastAccessedAt",
|
||||
s.views,
|
||||
s."createdAt",
|
||||
s."updatedAt",
|
||||
s."revokedAt",
|
||||
s."revokedById",
|
||||
d.title as "documentTitle",
|
||||
d.text as "documentText",
|
||||
u.name as "createdByName",
|
||||
u.email as "createdByEmail",
|
||||
ru.name as "revokedByName"
|
||||
FROM shares s
|
||||
LEFT JOIN documents d ON s."documentId" = d.id
|
||||
LEFT JOIN users u ON s."userId" = u.id
|
||||
LEFT JOIN users ru ON s."revokedById" = ru.id
|
||||
WHERE s.id = $1
|
||||
`;
|
||||
params = [args.id];
|
||||
} else if (args.document_id) {
|
||||
if (!isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
query = `
|
||||
SELECT
|
||||
s.id,
|
||||
s."urlId",
|
||||
s."documentId",
|
||||
s."userId",
|
||||
s."teamId",
|
||||
s."includeChildDocuments",
|
||||
s.published,
|
||||
s.domain,
|
||||
s."lastAccessedAt",
|
||||
s.views,
|
||||
s."createdAt",
|
||||
s."updatedAt",
|
||||
s."revokedAt",
|
||||
s."revokedById",
|
||||
d.title as "documentTitle",
|
||||
u.name as "createdByName"
|
||||
FROM shares s
|
||||
LEFT JOIN documents d ON s."documentId" = d.id
|
||||
LEFT JOIN users u ON s."userId" = u.id
|
||||
WHERE s."documentId" = $1 AND s."revokedAt" IS NULL
|
||||
ORDER BY s."createdAt" DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
params = [args.document_id];
|
||||
} else {
|
||||
throw new Error('Either id or document_id must be provided');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Share not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* shares.create - Create a new document share
|
||||
*/
|
||||
const createShare: BaseTool<CreateShareArgs> = {
|
||||
name: 'outline_shares_create',
|
||||
description: 'Create a new share link for a document. Returns the share details including the URL ID.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
document_id: {
|
||||
type: 'string',
|
||||
description: 'Document ID to share (UUID)',
|
||||
},
|
||||
published: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the share is published (default: true)',
|
||||
},
|
||||
include_child_documents: {
|
||||
type: 'boolean',
|
||||
description: 'Include child documents in the share (default: false)',
|
||||
},
|
||||
url_id: {
|
||||
type: 'string',
|
||||
description: 'Custom URL ID (optional, will be auto-generated if not provided)',
|
||||
},
|
||||
},
|
||||
required: ['document_id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.document_id)) {
|
||||
throw new Error('Invalid document_id format');
|
||||
}
|
||||
|
||||
if (args.url_id && !isValidUrlId(args.url_id)) {
|
||||
throw new Error('Invalid url_id format. Use only alphanumeric characters, hyphens, and underscores.');
|
||||
}
|
||||
|
||||
// Verify document exists
|
||||
const docCheck = await pgClient.query(
|
||||
'SELECT id, "teamId" FROM documents WHERE id = $1 AND "deletedAt" IS NULL',
|
||||
[args.document_id]
|
||||
);
|
||||
|
||||
if (docCheck.rows.length === 0) {
|
||||
throw new Error('Document not found or deleted');
|
||||
}
|
||||
|
||||
const teamId = docCheck.rows[0].teamId;
|
||||
|
||||
// Get first admin user as creator
|
||||
const userQuery = await pgClient.query(
|
||||
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
||||
);
|
||||
|
||||
if (userQuery.rows.length === 0) {
|
||||
throw new Error('No valid user found to create share');
|
||||
}
|
||||
|
||||
const userId = userQuery.rows[0].id;
|
||||
|
||||
// Generate urlId if not provided
|
||||
const urlId = args.url_id || `share-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const query = `
|
||||
INSERT INTO shares (
|
||||
"urlId",
|
||||
"documentId",
|
||||
"userId",
|
||||
"teamId",
|
||||
"includeChildDocuments",
|
||||
published,
|
||||
views,
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, 0, NOW(), NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [
|
||||
urlId,
|
||||
args.document_id,
|
||||
userId,
|
||||
teamId,
|
||||
args.include_child_documents || false,
|
||||
args.published !== false, // Default to true
|
||||
]);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* shares.update - Update an existing share
|
||||
*/
|
||||
const updateShare: BaseTool<UpdateShareArgs> = {
|
||||
name: 'outline_shares_update',
|
||||
description: 'Update an existing share. Can change published status or include child documents setting.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Share ID (UUID)',
|
||||
},
|
||||
published: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the share is published',
|
||||
},
|
||||
include_child_documents: {
|
||||
type: 'boolean',
|
||||
description: 'Include child documents in the share',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid share ID format');
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (args.published !== undefined) {
|
||||
updates.push(`published = $${paramIndex++}`);
|
||||
params.push(args.published);
|
||||
}
|
||||
|
||||
if (args.include_child_documents !== undefined) {
|
||||
updates.push(`"includeChildDocuments" = $${paramIndex++}`);
|
||||
params.push(args.include_child_documents);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No updates provided');
|
||||
}
|
||||
|
||||
updates.push(`"updatedAt" = NOW()`);
|
||||
params.push(args.id);
|
||||
|
||||
const query = `
|
||||
UPDATE shares
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex} AND "revokedAt" IS NULL
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Share not found or already revoked');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* shares.revoke - Revoke a share link
|
||||
*/
|
||||
const revokeShare: BaseTool<GetShareArgs> = {
|
||||
name: 'outline_shares_revoke',
|
||||
description: 'Revoke a share link, making it no longer accessible.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Share ID (UUID)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id!)) {
|
||||
throw new Error('Invalid share ID format');
|
||||
}
|
||||
|
||||
// Get first admin user as revoker
|
||||
const userQuery = await pgClient.query(
|
||||
'SELECT id FROM users WHERE "isAdmin" = true AND "deletedAt" IS NULL LIMIT 1'
|
||||
);
|
||||
|
||||
if (userQuery.rows.length === 0) {
|
||||
throw new Error('No valid user found to revoke share');
|
||||
}
|
||||
|
||||
const revokedById = userQuery.rows[0].id;
|
||||
|
||||
const query = `
|
||||
UPDATE shares
|
||||
SET
|
||||
"revokedAt" = NOW(),
|
||||
"revokedById" = $1,
|
||||
"updatedAt" = NOW()
|
||||
WHERE id = $2 AND "revokedAt" IS NULL
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pgClient.query(query, [revokedById, args.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('Share not found or already revoked');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
success: true,
|
||||
message: 'Share revoked successfully',
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all share tools
|
||||
export const sharesTools: BaseTool<any>[] = [
|
||||
listShares,
|
||||
getShare,
|
||||
createShare,
|
||||
updateShare,
|
||||
revokeShare,
|
||||
];
|
||||
660
src/tools/users.ts
Normal file
660
src/tools/users.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
/**
|
||||
* MCP Outline PostgreSQL - Users Tools
|
||||
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { BaseTool, ToolResponse, UserArgs, GetUserArgs, CreateUserArgs, UpdateUserArgs } from '../types/tools.js';
|
||||
import { validatePagination, isValidUUID, isValidEmail, sanitizeInput } from '../utils/security.js';
|
||||
|
||||
/**
|
||||
* users.list - List users with filtering
|
||||
*/
|
||||
const listUsers: BaseTool<UserArgs> = {
|
||||
name: 'outline_list_users',
|
||||
description: 'List users with optional filtering by query string, role, or status. Supports pagination and returns user profiles including roles and suspension status.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query to filter users by name or email',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
enum: ['all', 'admins', 'members', 'suspended', 'invited'],
|
||||
description: 'Filter users by role or status',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return (max 100)',
|
||||
default: 25,
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Number of results to skip for pagination',
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const { limit, offset } = validatePagination(args.limit, args.offset);
|
||||
const query = args.query ? sanitizeInput(args.query) : undefined;
|
||||
const filter = args.filter || 'all';
|
||||
|
||||
let whereConditions = ['u."deletedAt" IS NULL'];
|
||||
const queryParams: any[] = [limit, offset];
|
||||
let paramIndex = 3;
|
||||
|
||||
// Add search query filter
|
||||
if (query) {
|
||||
whereConditions.push(`(LOWER(u.name) LIKE LOWER($${paramIndex}) OR LOWER(u.email) LIKE LOWER($${paramIndex}))`);
|
||||
queryParams.push(`%${query}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
// Add role/status filters
|
||||
switch (filter) {
|
||||
case 'admins':
|
||||
whereConditions.push('u."isAdmin" = true');
|
||||
break;
|
||||
case 'members':
|
||||
whereConditions.push('u."isAdmin" = false AND u."isViewer" = false');
|
||||
break;
|
||||
case 'suspended':
|
||||
whereConditions.push('u."isSuspended" = true');
|
||||
break;
|
||||
case 'invited':
|
||||
whereConditions.push('u."lastSignedInAt" IS NULL');
|
||||
break;
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.username,
|
||||
u.name,
|
||||
u."avatarUrl",
|
||||
u.language,
|
||||
u.preferences,
|
||||
u."notificationSettings",
|
||||
u.timezone,
|
||||
u."isAdmin",
|
||||
u."isViewer",
|
||||
u."isSuspended",
|
||||
u."lastActiveAt",
|
||||
u."lastSignedInAt",
|
||||
u."suspendedAt",
|
||||
u."suspendedById",
|
||||
u."teamId",
|
||||
u."createdAt",
|
||||
u."updatedAt",
|
||||
t.name as "teamName",
|
||||
(SELECT COUNT(*) FROM users WHERE ${whereConditions.join(' AND ')}) as total
|
||||
FROM users u
|
||||
LEFT JOIN teams t ON u."teamId" = t.id
|
||||
${whereClause}
|
||||
ORDER BY u."createdAt" DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: {
|
||||
users: result.rows,
|
||||
total: result.rows.length > 0 ? parseInt(result.rows[0].total) : 0,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* users.info - Get user details by ID
|
||||
*/
|
||||
const getUser: BaseTool<GetUserArgs> = {
|
||||
name: 'outline_get_user',
|
||||
description: 'Get detailed information about a specific user by their ID. Returns full user profile including preferences, permissions, and activity.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'User UUID',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.username,
|
||||
u.name,
|
||||
u."avatarUrl",
|
||||
u.language,
|
||||
u.preferences,
|
||||
u."notificationSettings",
|
||||
u.timezone,
|
||||
u."isAdmin",
|
||||
u."isViewer",
|
||||
u."isSuspended",
|
||||
u."lastActiveAt",
|
||||
u."lastSignedInAt",
|
||||
u."suspendedAt",
|
||||
u."suspendedById",
|
||||
u."teamId",
|
||||
u."createdAt",
|
||||
u."updatedAt",
|
||||
t.name as "teamName",
|
||||
suspender.name as "suspendedByName",
|
||||
(SELECT COUNT(*) FROM documents WHERE "createdById" = u.id AND "deletedAt" IS NULL) as "documentCount",
|
||||
(SELECT COUNT(*) FROM collections WHERE "createdById" = u.id AND "deletedAt" IS NULL) as "collectionCount"
|
||||
FROM users u
|
||||
LEFT JOIN teams t ON u."teamId" = t.id
|
||||
LEFT JOIN users suspender ON u."suspendedById" = suspender.id
|
||||
WHERE u.id = $1 AND u."deletedAt" IS NULL
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* users.create - Create new user
|
||||
*/
|
||||
const createUser: BaseTool<CreateUserArgs> = {
|
||||
name: 'outline_create_user',
|
||||
description: 'Create a new user with specified name, email, and optional role. User will be added to the team associated with the database.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Full name of the user',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
description: 'Email address (must be unique)',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
enum: ['admin', 'member', 'viewer'],
|
||||
description: 'User role (default: member)',
|
||||
default: 'member',
|
||||
},
|
||||
},
|
||||
required: ['name', 'email'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
const name = sanitizeInput(args.name);
|
||||
const email = sanitizeInput(args.email);
|
||||
const role = args.role || 'member';
|
||||
|
||||
if (!isValidEmail(email)) {
|
||||
throw new Error('Invalid email format');
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await pgClient.query(
|
||||
`SELECT id FROM users WHERE email = $1`,
|
||||
[email]
|
||||
);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
throw new Error('User with this email already exists');
|
||||
}
|
||||
|
||||
// Get team ID (assuming first team, adjust as needed)
|
||||
const teamResult = await pgClient.query(`SELECT id FROM teams LIMIT 1`);
|
||||
if (teamResult.rows.length === 0) {
|
||||
throw new Error('No team found');
|
||||
}
|
||||
const teamId = teamResult.rows[0].id;
|
||||
|
||||
const isAdmin = role === 'admin';
|
||||
const isViewer = role === 'viewer';
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
INSERT INTO users (
|
||||
id, email, name, "teamId", "isAdmin", "isViewer",
|
||||
"createdAt", "updatedAt"
|
||||
)
|
||||
VALUES (
|
||||
gen_random_uuid(), $1, $2, $3, $4, $5,
|
||||
NOW(), NOW()
|
||||
)
|
||||
RETURNING *
|
||||
`,
|
||||
[email, name, teamId, isAdmin, isViewer]
|
||||
);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'User created successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* users.update - Update user details
|
||||
*/
|
||||
const updateUser: BaseTool<UpdateUserArgs> = {
|
||||
name: 'outline_update_user',
|
||||
description: 'Update user profile information such as name, avatar, or language preferences.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'User UUID',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'Updated full name',
|
||||
},
|
||||
avatar_url: {
|
||||
type: 'string',
|
||||
description: 'URL to avatar image',
|
||||
},
|
||||
language: {
|
||||
type: 'string',
|
||||
description: 'Language code (e.g., en_US, pt_PT)',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (args.name !== undefined) {
|
||||
updates.push(`name = $${paramIndex++}`);
|
||||
values.push(sanitizeInput(args.name));
|
||||
}
|
||||
|
||||
if (args.avatar_url !== undefined) {
|
||||
updates.push(`"avatarUrl" = $${paramIndex++}`);
|
||||
values.push(sanitizeInput(args.avatar_url));
|
||||
}
|
||||
|
||||
if (args.language !== undefined) {
|
||||
updates.push(`language = $${paramIndex++}`);
|
||||
values.push(sanitizeInput(args.language));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new Error('No updates provided');
|
||||
}
|
||||
|
||||
updates.push(`"updatedAt" = NOW()`);
|
||||
values.push(args.id);
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramIndex} AND "deletedAt" IS NULL
|
||||
RETURNING *
|
||||
`,
|
||||
values
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'User updated successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* users.delete - Soft delete user
|
||||
*/
|
||||
const deleteUser: BaseTool<GetUserArgs> = {
|
||||
name: 'outline_delete_user',
|
||||
description: 'Soft delete a user. This marks the user as deleted but preserves their data for audit purposes.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'User UUID to delete',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET "deletedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, email, name
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found or already deleted');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'User deleted successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* users.suspend - Suspend user account
|
||||
*/
|
||||
const suspendUser: BaseTool<GetUserArgs> = {
|
||||
name: 'outline_suspend_user',
|
||||
description: 'Suspend a user account. Suspended users cannot access the system but their data is preserved.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'User UUID to suspend',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET "isSuspended" = true, "suspendedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, email, name, "isSuspended", "suspendedAt"
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'User suspended successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* users.activate - Activate suspended user
|
||||
*/
|
||||
const activateUser: BaseTool<GetUserArgs> = {
|
||||
name: 'outline_activate_user',
|
||||
description: 'Reactivate a suspended user account, restoring their access to the system.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'User UUID to activate',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET "isSuspended" = false, "suspendedAt" = NULL, "suspendedById" = NULL
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, email, name, "isSuspended"
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'User activated successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* users.promote - Promote user to admin
|
||||
*/
|
||||
const promoteUser: BaseTool<GetUserArgs> = {
|
||||
name: 'outline_promote_user',
|
||||
description: 'Promote a user to admin role, granting them full administrative permissions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'User UUID to promote',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET "isAdmin" = true, "isViewer" = false, "updatedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, email, name, "isAdmin", "isViewer"
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'User promoted to admin successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* users.demote - Demote admin to member
|
||||
*/
|
||||
const demoteUser: BaseTool<GetUserArgs> = {
|
||||
name: 'outline_demote_user',
|
||||
description: 'Demote an admin user to regular member role, removing administrative permissions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'User UUID to demote',
|
||||
},
|
||||
},
|
||||
required: ['id'],
|
||||
},
|
||||
handler: async (args, pgClient): Promise<ToolResponse> => {
|
||||
if (!isValidUUID(args.id)) {
|
||||
throw new Error('Invalid user ID format. Must be a valid UUID.');
|
||||
}
|
||||
|
||||
const result = await pgClient.query(
|
||||
`
|
||||
UPDATE users
|
||||
SET "isAdmin" = false, "updatedAt" = NOW()
|
||||
WHERE id = $1 AND "deletedAt" IS NULL
|
||||
RETURNING id, email, name, "isAdmin", "isViewer"
|
||||
`,
|
||||
[args.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify(
|
||||
{
|
||||
data: result.rows[0],
|
||||
message: 'User demoted to member successfully',
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Export all user tools
|
||||
export const usersTools: BaseTool<any>[] = [
|
||||
listUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
suspendUser,
|
||||
activateUser,
|
||||
promoteUser,
|
||||
demoteUser,
|
||||
];
|
||||
Reference in New Issue
Block a user