feat: Initial release MCP Outline PostgreSQL v1.0.0

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

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

52
src/config/database.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* MCP Outline PostgreSQL - Database Configuration
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import * as dotenv from 'dotenv';
dotenv.config();
export interface DatabaseConfig {
host: string;
port: number;
user: string;
password: string;
database: string;
ssl?: boolean;
connectionString?: string;
max?: number; // Max pool size
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
}
export function getDatabaseConfig(): DatabaseConfig {
// If DATABASE_URL is provided, use it
if (process.env.DATABASE_URL) {
return {
connectionString: process.env.DATABASE_URL,
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER || 'outline',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'outline',
ssl: process.env.DB_SSL === 'true',
max: parseInt(process.env.DB_POOL_SIZE || '10', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10)
};
}
// Otherwise, use individual environment variables
return {
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
user: process.env.DB_USER || 'outline',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'outline',
ssl: process.env.DB_SSL === 'true',
max: parseInt(process.env.DB_POOL_SIZE || '10', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10)
};
}

199
src/index.ts Normal file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env node
/**
* MCP Outline PostgreSQL - Main Server
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
ListToolsRequestSchema,
CallToolRequestSchema,
ListResourcesRequestSchema,
ListPromptsRequestSchema
} from '@modelcontextprotocol/sdk/types.js';
import * as dotenv from 'dotenv';
import { PgClient } from './pg-client.js';
import { getDatabaseConfig } from './config/database.js';
import { logger } from './utils/logger.js';
import { checkRateLimit } from './utils/security.js';
import { BaseTool } from './types/tools.js';
// Import ALL tools
import {
documentsTools,
collectionsTools,
usersTools,
groupsTools,
commentsTools,
sharesTools,
revisionsTools,
eventsTools,
attachmentsTools,
fileOperationsTools,
oauthTools,
authTools
} from './tools/index.js';
dotenv.config();
// Combine ALL tools into single array
const allTools: BaseTool[] = [
// Core functionality
...documentsTools,
...collectionsTools,
...usersTools,
...groupsTools,
// Collaboration
...commentsTools,
...sharesTools,
...revisionsTools,
// System
...eventsTools,
...attachmentsTools,
...fileOperationsTools,
// Authentication
...oauthTools,
...authTools
];
// Validate all tools have required properties
const invalidTools = allTools.filter((tool) => !tool.name || !tool.handler);
if (invalidTools.length > 0) {
logger.error(`${invalidTools.length} invalid tools found`);
process.exit(1);
}
async function main() {
// Get database configuration
const config = getDatabaseConfig();
// Initialize PostgreSQL client
const pgClient = new PgClient(config);
// Test database connection
const isConnected = await pgClient.testConnection();
if (!isConnected) {
throw new Error('Failed to connect to PostgreSQL database');
}
// Initialize MCP server
const server = new Server({
name: 'mcp-outline',
version: '1.0.0'
});
// Set capabilities (required for MCP v2.2+)
(server as any)._capabilities = {
tools: {},
resources: {},
prompts: {}
};
// Connect transport BEFORE registering handlers
const transport = new StdioServerTransport();
await server.connect(transport);
// Register tools list handler
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: allTools.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema
}))
}));
// Register resources handler (required even if empty)
server.setRequestHandler(ListResourcesRequestSchema, async () => {
logger.debug('Resources list requested');
return { resources: [] };
});
// Register prompts handler (required even if empty)
server.setRequestHandler(ListPromptsRequestSchema, async () => {
logger.debug('Prompts list requested');
return { prompts: [] };
});
// Register tool call handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
// Rate limiting (using 'default' as clientId for now)
const clientId = process.env.CLIENT_ID || 'default';
if (!checkRateLimit('api', clientId)) {
return {
content: [
{ type: 'text', text: 'Too Many Requests: rate limit exceeded. Try again later.' }
]
};
}
// Find the tool handler
const tool = allTools.find((t) => t.name === name);
if (!tool) {
return {
content: [
{
type: 'text',
text: `Tool '${name}' not found`
}
]
};
}
try {
// Pass the pool directly to tool handlers
return await tool.handler(args as Record<string, unknown>, pgClient.getPool());
} catch (error) {
logger.error(`Error in tool ${name}:`, {
error: error instanceof Error ? error.message : String(error)
});
return {
content: [
{
type: 'text',
text: `Error in tool ${name}: ${error instanceof Error ? error.message : String(error)}`
}
]
};
}
});
// Log startup (minimal logging for MCP protocol compatibility)
if (process.env.LOG_LEVEL !== 'error' && process.env.LOG_LEVEL !== 'none') {
logger.info('MCP Server started');
}
// Debug logging
logger.debug('MCP Outline PostgreSQL Server running', {
totalTools: allTools.length,
toolsByModule: {
documents: documentsTools.length,
collections: collectionsTools.length,
users: usersTools.length,
groups: groupsTools.length,
comments: commentsTools.length,
shares: sharesTools.length,
revisions: revisionsTools.length,
events: eventsTools.length,
attachments: attachmentsTools.length,
fileOperations: fileOperationsTools.length,
oauth: oauthTools.length,
auth: authTools.length
}
});
}
main().catch((error) => {
logger.error('Fatal error', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined
});
process.exit(1);
});

158
src/pg-client.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* MCP Outline PostgreSQL - PostgreSQL Client
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { Pool, PoolConfig, QueryResult, QueryResultRow } from 'pg';
import { DatabaseConfig } from './config/database.js';
import { logger } from './utils/logger.js';
export class PgClient {
private pool: Pool;
private isConnected: boolean = false;
constructor(config: DatabaseConfig) {
const poolConfig: PoolConfig = config.connectionString
? {
connectionString: config.connectionString,
max: config.max,
idleTimeoutMillis: config.idleTimeoutMillis,
connectionTimeoutMillis: config.connectionTimeoutMillis
}
: {
host: config.host,
port: config.port,
user: config.user,
password: config.password,
database: config.database,
ssl: config.ssl ? { rejectUnauthorized: false } : false,
max: config.max,
idleTimeoutMillis: config.idleTimeoutMillis,
connectionTimeoutMillis: config.connectionTimeoutMillis
};
this.pool = new Pool(poolConfig);
// Handle pool errors
this.pool.on('error', (err) => {
logger.error('Unexpected PostgreSQL pool error', { error: err.message });
});
}
/**
* Get the underlying pool for direct access
*/
getPool(): Pool {
return this.pool;
}
/**
* Test database connection
*/
async testConnection(): Promise<boolean> {
try {
const client = await this.pool.connect();
await client.query('SELECT 1');
client.release();
this.isConnected = true;
logger.info('PostgreSQL connection successful');
return true;
} catch (error) {
logger.error('PostgreSQL connection failed', {
error: error instanceof Error ? error.message : String(error)
});
this.isConnected = false;
return false;
}
}
/**
* Execute a query with parameters
*/
async query<T extends QueryResultRow = any>(sql: string, params?: any[]): Promise<T[]> {
const start = Date.now();
try {
const result = await this.pool.query<T>(sql, params);
const duration = Date.now() - start;
logger.debug('Query executed', {
sql: sql.substring(0, 100),
duration,
rowCount: result.rowCount
});
return result.rows;
} catch (error) {
const duration = Date.now() - start;
logger.error('Query failed', {
sql: sql.substring(0, 100),
duration,
error: error instanceof Error ? error.message : String(error)
});
throw error;
}
}
/**
* Execute a query and return the full result
*/
async queryRaw<T extends QueryResultRow = any>(sql: string, params?: any[]): Promise<QueryResult<T>> {
return this.pool.query<T>(sql, params);
}
/**
* Execute a query and return a single row
*/
async queryOne<T extends QueryResultRow = any>(sql: string, params?: any[]): Promise<T | null> {
const rows = await this.query<T>(sql, params);
return rows.length > 0 ? rows[0] : null;
}
/**
* Execute multiple queries in a transaction
*/
async transaction<T>(callback: (client: any) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
/**
* Close the pool
*/
async close(): Promise<void> {
await this.pool.end();
this.isConnected = false;
logger.info('PostgreSQL pool closed');
}
/**
* Check if connected
*/
isPoolConnected(): boolean {
return this.isConnected;
}
}
// Export a singleton factory
let instance: PgClient | null = null;
export function createPgClient(config: DatabaseConfig): PgClient {
if (!instance) {
instance = new PgClient(config);
}
return instance;
}
export function getPgClient(): PgClient | null {
return instance;
}

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

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

159
src/tools/auth.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

480
src/tools/comments.ts Normal file
View 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

File diff suppressed because it is too large Load Diff

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

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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,
];

324
src/types/db.ts Normal file
View File

@@ -0,0 +1,324 @@
/**
* MCP Outline PostgreSQL - Database Types
* Based on Outline PostgreSQL schema
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
// Base row type for all database queries
export interface DatabaseRow {
[key: string]: unknown;
}
// Document entity
export interface Document extends DatabaseRow {
id: string;
urlId: string;
title: string;
text: string;
emoji?: string;
collectionId: string;
parentDocumentId?: string;
createdById: string;
lastModifiedById?: string;
publishedAt?: Date;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
archivedAt?: Date;
template: boolean;
templateId?: string;
fullWidth: boolean;
insightsEnabled: boolean;
sourceMetadata?: object;
version?: number;
}
// Collection entity
export interface Collection extends DatabaseRow {
id: string;
urlId: string;
name: string;
description?: string;
icon?: string;
color?: string;
index?: string;
permission?: string;
maintainerApprovalRequired: boolean;
documentStructure?: object;
sharing: boolean;
sort?: object;
teamId: string;
createdById: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
archivedAt?: Date;
}
// User entity
export interface User extends DatabaseRow {
id: string;
email: string;
username?: string;
name: string;
avatarUrl?: string;
language?: string;
preferences?: object;
notificationSettings?: object;
timezone?: string;
isAdmin: boolean;
isViewer: boolean;
isSuspended: boolean;
lastActiveAt?: Date;
lastActiveIp?: string;
lastSignedInAt?: Date;
lastSignedInIp?: string;
lastSigninEmailSentAt?: Date;
suspendedAt?: Date;
suspendedById?: string;
teamId: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
// Team entity
export interface Team extends DatabaseRow {
id: string;
name: string;
subdomain?: string;
domain?: string;
defaultCollectionId?: string;
avatarUrl?: string;
sharing: boolean;
inviteRequired: boolean;
memberCollectionCreate: boolean;
guestSignin: boolean;
documentEmbeds: boolean;
collaborativeEditing: boolean;
defaultUserRole: string;
createdAt: Date;
updatedAt: Date;
}
// Group entity
export interface Group extends DatabaseRow {
id: string;
name: string;
teamId: string;
createdById: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
// GroupUser entity
export interface GroupUser extends DatabaseRow {
id: string;
userId: string;
groupId: string;
createdById: string;
createdAt: Date;
updatedAt: Date;
}
// Comment entity
export interface Comment extends DatabaseRow {
id: string;
data: object;
documentId: string;
parentCommentId?: string;
createdById: string;
resolvedById?: string;
resolvedAt?: Date;
createdAt: Date;
updatedAt: Date;
}
// Share entity
export interface Share extends DatabaseRow {
id: string;
urlId: string;
documentId: string;
userId: string;
teamId: string;
includeChildDocuments: boolean;
published: boolean;
domain?: string;
lastAccessedAt?: Date;
views: number;
createdAt: Date;
updatedAt: Date;
revokedAt?: Date;
revokedById?: string;
}
// Revision entity
export interface Revision extends DatabaseRow {
id: string;
version: number;
editorVersion?: string;
title: string;
text: string;
emoji?: string;
documentId: string;
userId: string;
createdAt: Date;
}
// Event entity
export interface Event extends DatabaseRow {
id: string;
name: string;
modelId?: string;
actorId?: string;
userId?: string;
collectionId?: string;
documentId?: string;
teamId: string;
ip?: string;
data?: object;
createdAt: Date;
}
// Attachment entity
export interface Attachment extends DatabaseRow {
id: string;
key: string;
url: string;
contentType: string;
size: number;
acl: string;
documentId?: string;
userId: string;
teamId: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
// CollectionUser entity (permissions)
export interface CollectionUser extends DatabaseRow {
id: string;
collectionId: string;
userId: string;
permission: string;
createdById: string;
createdAt: Date;
updatedAt: Date;
}
// CollectionGroup entity (group permissions)
export interface CollectionGroup extends DatabaseRow {
id: string;
collectionId: string;
groupId: string;
permission: string;
createdById: string;
createdAt: Date;
updatedAt: Date;
}
// Star entity
export interface Star extends DatabaseRow {
id: string;
index?: string;
documentId?: string;
collectionId?: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}
// Pin entity
export interface Pin extends DatabaseRow {
id: string;
index?: string;
documentId: string;
collectionId?: string;
teamId: string;
createdById: string;
createdAt: Date;
updatedAt: Date;
}
// View entity
export interface View extends DatabaseRow {
id: string;
documentId: string;
userId: string;
count: number;
createdAt: Date;
updatedAt: Date;
}
// ApiKey entity
export interface ApiKey extends DatabaseRow {
id: string;
name: string;
secret: string;
hash: string;
lastActiveAt?: Date;
expiresAt?: Date;
userId: string;
createdAt: Date;
updatedAt: Date;
deletedAt?: Date;
}
// FileOperation entity
export interface FileOperation extends DatabaseRow {
id: string;
type: string;
state: string;
format?: string;
size?: number;
key?: string;
url?: string;
error?: string;
collectionId?: string;
userId: string;
teamId: string;
createdAt: Date;
updatedAt: Date;
}
// WebhookSubscription entity
export interface WebhookSubscription extends DatabaseRow {
id: string;
name: string;
url: string;
enabled: boolean;
events: string[];
secret?: string;
teamId: string;
createdById: string;
createdAt: Date;
updatedAt: Date;
}
// SearchQuery entity
export interface SearchQuery extends DatabaseRow {
id: string;
query: string;
results: number;
source: string;
userId: string;
teamId: string;
createdAt: Date;
}
// Integration entity
export interface Integration extends DatabaseRow {
id: string;
type: string;
service: string;
events: string[];
settings?: object;
authentication?: object;
collectionId?: string;
teamId: string;
userId: string;
createdAt: Date;
updatedAt: Date;
}

7
src/types/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* MCP Outline PostgreSQL - Types Index
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
export * from './tools.js';
export * from './db.js';

292
src/types/tools.ts Normal file
View File

@@ -0,0 +1,292 @@
/**
* MCP Outline PostgreSQL - Tool Types
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { Pool } from 'pg';
// Base types for all tools
export interface ToolResponse {
content: Array<{
type: 'text';
text: string;
}>;
[key: string]: unknown; // Index signature for MCP SDK compatibility
}
export interface BaseTool<TArgs = Record<string, unknown>> {
name: string;
description: string;
inputSchema: {
type: string;
properties: Record<string, unknown>;
required?: string[];
};
handler: (args: TArgs, pgClient: Pool) => Promise<ToolResponse>;
}
// Common argument types
export interface PaginationArgs {
limit?: number;
offset?: number;
}
export interface DateRangeArgs {
date_from?: string;
date_to?: string;
}
export interface SortArgs {
sort?: string;
direction?: 'ASC' | 'DESC';
}
// Document specific types
export interface DocumentArgs extends PaginationArgs, SortArgs {
collection_id?: string;
user_id?: string;
template?: boolean;
published?: boolean;
archived?: boolean;
}
export interface GetDocumentArgs {
id?: string;
share_id?: string;
}
export interface CreateDocumentArgs {
title: string;
text?: string;
collection_id: string;
parent_document_id?: string;
template?: boolean;
publish?: boolean;
}
export interface UpdateDocumentArgs {
id: string;
title?: string;
text?: string;
done?: boolean;
append?: boolean;
}
export interface SearchDocumentsArgs extends PaginationArgs {
query: string;
collection_id?: string;
user_id?: string;
include_archived?: boolean;
include_drafts?: boolean;
}
export interface MoveDocumentArgs {
id: string;
collection_id?: string;
parent_document_id?: string;
}
// Collection specific types
export interface CollectionArgs extends PaginationArgs {
include_deleted?: boolean;
}
export interface GetCollectionArgs {
id: string;
}
export interface CreateCollectionArgs {
name: string;
description?: string;
color?: string;
permission?: 'read' | 'read_write';
sharing?: boolean;
icon?: string;
sort?: { field: string; direction: 'asc' | 'desc' };
}
export interface UpdateCollectionArgs {
id: string;
name?: string;
description?: string;
color?: string;
permission?: 'read' | 'read_write';
sharing?: boolean;
icon?: string;
sort?: { field: string; direction: 'asc' | 'desc' };
}
// User specific types
export interface UserArgs extends PaginationArgs {
query?: string;
filter?: 'all' | 'admins' | 'members' | 'suspended' | 'invited';
}
export interface GetUserArgs {
id: string;
}
export interface CreateUserArgs {
name: string;
email: string;
role?: 'admin' | 'member' | 'viewer';
}
export interface UpdateUserArgs {
id: string;
name?: string;
avatar_url?: string;
language?: string;
}
// Group specific types
export interface GroupArgs extends PaginationArgs {
query?: string;
}
export interface GetGroupArgs {
id: string;
}
export interface CreateGroupArgs {
name: string;
}
export interface UpdateGroupArgs {
id: string;
name: string;
}
// Comment specific types
export interface CommentArgs extends PaginationArgs {
document_id?: string;
collection_id?: string;
}
export interface GetCommentArgs {
id: string;
}
export interface CreateCommentArgs {
document_id: string;
data: object;
parent_comment_id?: string;
}
export interface UpdateCommentArgs {
id: string;
data: object;
}
// Share specific types
export interface ShareArgs extends PaginationArgs {
document_id?: string;
}
export interface GetShareArgs {
id?: string;
document_id?: string;
}
export interface CreateShareArgs {
document_id: string;
published?: boolean;
include_child_documents?: boolean;
url_id?: string;
}
export interface UpdateShareArgs {
id: string;
published?: boolean;
include_child_documents?: boolean;
}
// Revision specific types
export interface RevisionArgs extends PaginationArgs {
document_id: string;
}
export interface GetRevisionArgs {
id: string;
}
// Event specific types
export interface EventArgs extends PaginationArgs, DateRangeArgs {
name?: string;
actor_id?: string;
document_id?: string;
collection_id?: string;
audit_log?: boolean;
}
// Attachment specific types
export interface CreateAttachmentArgs {
name: string;
document_id?: string;
content_type: string;
size: number;
}
export interface GetAttachmentArgs {
id: string;
}
// File Operation specific types
export interface FileOperationArgs extends PaginationArgs {
type?: 'import' | 'export';
}
export interface GetFileOperationArgs {
id: string;
}
// OAuth Client specific types
export interface OAuthClientArgs extends PaginationArgs {}
export interface GetOAuthClientArgs {
id: string;
}
export interface CreateOAuthClientArgs {
name: string;
redirect_uris: string[];
description?: string;
}
export interface UpdateOAuthClientArgs {
id: string;
name?: string;
redirect_uris?: string[];
description?: string;
}
// Membership specific types
export interface MembershipArgs extends PaginationArgs {
query?: string;
permission?: 'read' | 'read_write' | 'admin';
}
export interface AddMemberArgs {
id: string;
user_id: string;
permission?: 'read' | 'read_write' | 'admin';
}
export interface RemoveMemberArgs {
id: string;
user_id: string;
}
export interface AddGroupMemberArgs {
id: string;
group_id: string;
permission?: 'read' | 'read_write' | 'admin';
}
export interface RemoveGroupMemberArgs {
id: string;
group_id: string;
}
// Helper type to ensure type safety
export type ToolHandler<TArgs> = (args: TArgs, pgClient: Pool) => Promise<ToolResponse>;

7
src/utils/index.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* MCP Outline PostgreSQL - Utils Index
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
export * from './logger.js';
export * from './security.js';

90
src/utils/logger.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* MCP Outline PostgreSQL - Logger
* Optimized for MCP - reduce logs to not exhaust Claude context
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
type LogLevel = 'error' | 'warn' | 'info' | 'debug';
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
data?: Record<string, unknown>;
}
const LOG_LEVELS: Record<LogLevel, number> = {
error: 0,
warn: 1,
info: 2,
debug: 3
};
class Logger {
private level: LogLevel;
constructor() {
this.level = (process.env.LOG_LEVEL as LogLevel) || 'error';
}
private shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] <= LOG_LEVELS[this.level];
}
private formatLog(level: LogLevel, message: string, data?: Record<string, unknown>): string {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
...(data && { data })
};
return JSON.stringify(entry);
}
private write(level: LogLevel, message: string, data?: Record<string, unknown>): void {
if (!this.shouldLog(level)) return;
const formatted = this.formatLog(level, message, data);
// For MCP, send logs to stderr
if (process.env.MCP_MODE !== 'false') {
process.stderr.write(formatted + '\n');
} else {
console.log(formatted);
}
}
error(message: string, data?: Record<string, unknown>): void {
this.write('error', message, data);
}
warn(message: string, data?: Record<string, unknown>): void {
this.write('warn', message, data);
}
info(message: string, data?: Record<string, unknown>): void {
this.write('info', message, data);
}
debug(message: string, data?: Record<string, unknown>): void {
this.write('debug', message, data);
}
}
export const logger = new Logger();
// Log queries for auditing (if enabled) - OPTIMIZED
export function logQuery(
sql: string,
_params?: any[],
duration?: number,
_clientId?: string
): void {
// DISABLED by default to save Claude context
if (process.env.ENABLE_AUDIT_LOG === 'true' && process.env.NODE_ENV !== 'production') {
logger.debug('SQL', {
sql: sql.substring(0, 50),
duration
});
}
}

115
src/utils/security.ts Normal file
View File

@@ -0,0 +1,115 @@
/**
* MCP Outline PostgreSQL - Security Utilities
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
// Rate limiting store
const rateLimitStore: Map<string, { count: number; resetAt: number }> = new Map();
// Rate limit configuration
const RATE_LIMIT_WINDOW = 60000; // 1 minute
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '100', 10);
/**
* Check if a request should be rate limited
*/
export function checkRateLimit(type: string, clientId: string): boolean {
const key = `${type}:${clientId}`;
const now = Date.now();
const entry = rateLimitStore.get(key);
if (!entry || now > entry.resetAt) {
rateLimitStore.set(key, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
return true;
}
if (entry.count >= RATE_LIMIT_MAX) {
return false;
}
entry.count++;
return true;
}
/**
* Sanitize SQL input to prevent injection
* Note: Always use parameterized queries, this is a secondary safety measure
*/
export function sanitizeInput(input: string): string {
if (typeof input !== 'string') return input;
// Remove null bytes
let sanitized = input.replace(/\0/g, '');
// Trim whitespace
sanitized = sanitized.trim();
return sanitized;
}
/**
* Validate UUID format
*/
export function isValidUUID(uuid: string): boolean {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(uuid);
}
/**
* Validate URL ID format (Outline uses URL-safe IDs)
*/
export function isValidUrlId(urlId: string): boolean {
const urlIdRegex = /^[a-zA-Z0-9_-]+$/;
return urlIdRegex.test(urlId);
}
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Escape HTML entities for safe display
*/
export function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (char) => map[char]);
}
/**
* Validate pagination parameters
*/
export function validatePagination(limit?: number, offset?: number): { limit: number; offset: number } {
const maxLimit = 100;
const defaultLimit = 25;
return {
limit: Math.min(Math.max(1, limit || defaultLimit), maxLimit),
offset: Math.max(0, offset || 0)
};
}
/**
* Validate sort direction
*/
export function validateSortDirection(direction?: string): 'ASC' | 'DESC' {
const upper = (direction || 'DESC').toUpperCase();
return upper === 'ASC' ? 'ASC' : 'DESC';
}
/**
* Validate sort field against allowed fields
*/
export function validateSortField(field: string | undefined, allowedFields: string[], defaultField: string): string {
if (!field) return defaultField;
return allowedFields.includes(field) ? field : defaultField;
}