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