/** * MCP Outline PostgreSQL - Teams Tools * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 */ import { Pool } from 'pg'; import { BaseTool, ToolResponse } from '../types/tools.js'; import { isValidUUID, sanitizeInput } from '../utils/security.js'; /** * teams.info - Get team details */ const getTeam: BaseTool<{ id?: string }> = { name: 'outline_get_team', description: 'Get detailed information about a team (workspace). If no ID provided, returns the first/default team.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Team ID (UUID, optional)' }, }, }, handler: async (args, pgClient): Promise => { let query = ` SELECT t.id, t.name, t.subdomain, t.domain, t."avatarUrl", t.sharing, t."documentEmbeds", t."guestSignin", t."inviteRequired", t."collaborativeEditing", t."defaultUserRole", t."memberCollectionCreate", t."createdAt", t."updatedAt", (SELECT COUNT(*) FROM users WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "userCount", (SELECT COUNT(*) FROM collections WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "collectionCount", (SELECT COUNT(*) FROM documents WHERE "teamId" = t.id AND "deletedAt" IS NULL) as "documentCount" FROM teams t WHERE t."deletedAt" IS NULL `; const params: any[] = []; if (args.id) { if (!isValidUUID(args.id)) throw new Error('Invalid team ID format'); query += ` AND t.id = $1`; params.push(args.id); } query += ` LIMIT 1`; const result = await pgClient.query(query, params); if (result.rows.length === 0) throw new Error('Team not found'); return { content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }], }; }, }; /** * teams.update - Update team settings */ const updateTeam: BaseTool<{ id: string; name?: string; sharing?: boolean; document_embeds?: boolean; guest_signin?: boolean; invite_required?: boolean; default_user_role?: string; }> = { name: 'outline_update_team', description: 'Update team settings and preferences.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Team ID (UUID)' }, name: { type: 'string', description: 'Team name' }, sharing: { type: 'boolean', description: 'Allow document sharing' }, document_embeds: { type: 'boolean', description: 'Allow document embeds' }, guest_signin: { type: 'boolean', description: 'Allow guest signin' }, invite_required: { type: 'boolean', description: 'Require invite to join' }, default_user_role: { type: 'string', enum: ['admin', 'member', 'viewer'], description: 'Default role for new users' }, }, required: ['id'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.id)) throw new Error('Invalid team ID format'); const updates: string[] = ['"updatedAt" = NOW()']; const params: any[] = []; let idx = 1; if (args.name) { updates.push(`name = $${idx++}`); params.push(sanitizeInput(args.name)); } if (args.sharing !== undefined) { updates.push(`sharing = $${idx++}`); params.push(args.sharing); } if (args.document_embeds !== undefined) { updates.push(`"documentEmbeds" = $${idx++}`); params.push(args.document_embeds); } if (args.guest_signin !== undefined) { updates.push(`"guestSignin" = $${idx++}`); params.push(args.guest_signin); } if (args.invite_required !== undefined) { updates.push(`"inviteRequired" = $${idx++}`); params.push(args.invite_required); } if (args.default_user_role) { updates.push(`"defaultUserRole" = $${idx++}`); params.push(args.default_user_role); } params.push(args.id); const result = await pgClient.query( `UPDATE teams SET ${updates.join(', ')} WHERE id = $${idx} AND "deletedAt" IS NULL RETURNING *`, params ); if (result.rows.length === 0) throw new Error('Team not found'); return { content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Team updated' }, null, 2) }], }; }, }; /** * teams.stats - Get team statistics */ const getTeamStats: BaseTool<{ id?: string }> = { name: 'outline_get_team_stats', description: 'Get comprehensive statistics for a team including users, documents, collections, and activity.', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Team ID (UUID, optional - uses default team)' }, }, }, handler: async (args, pgClient): Promise => { let teamCondition = ''; const params: any[] = []; if (args.id) { if (!isValidUUID(args.id)) throw new Error('Invalid team ID format'); teamCondition = `AND "teamId" = $1`; params.push(args.id); } const stats = await pgClient.query(` SELECT (SELECT COUNT(*) FROM users WHERE "deletedAt" IS NULL ${teamCondition}) as "totalUsers", (SELECT COUNT(*) FROM users WHERE role = 'admin' AND "deletedAt" IS NULL ${teamCondition}) as "adminUsers", (SELECT COUNT(*) FROM users WHERE "suspendedAt" IS NOT NULL AND "deletedAt" IS NULL ${teamCondition}) as "suspendedUsers", (SELECT COUNT(*) FROM documents WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "totalDocuments", (SELECT COUNT(*) FROM documents WHERE template = true AND "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "templateDocuments", (SELECT COUNT(*) FROM documents WHERE "publishedAt" IS NOT NULL AND "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'd."teamId"')}) as "publishedDocuments", (SELECT COUNT(*) FROM collections WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'c."teamId"')}) as "totalCollections", (SELECT COUNT(*) FROM groups WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'g."teamId"')}) as "totalGroups", (SELECT COUNT(*) FROM shares ${args.id ? 'WHERE "teamId" = $1' : ''}) as "totalShares", (SELECT COUNT(*) FROM integrations WHERE "deletedAt" IS NULL ${teamCondition.replace('"teamId"', 'i."teamId"')}) as "totalIntegrations" `, params); return { content: [{ type: 'text', text: JSON.stringify({ data: stats.rows[0] }, null, 2) }], }; }, }; /** * teams.domains - List team domains */ const listTeamDomains: BaseTool<{ team_id?: string }> = { name: 'outline_list_team_domains', description: 'List allowed domains for a team. Domains control who can sign up.', inputSchema: { type: 'object', properties: { team_id: { type: 'string', description: 'Team ID (UUID, optional)' }, }, }, handler: async (args, pgClient): Promise => { let query = ` SELECT td.id, td.name, td."teamId", td."createdById", td."createdAt", u.name as "createdByName", t.name as "teamName" FROM team_domains td LEFT JOIN users u ON td."createdById" = u.id LEFT JOIN teams t ON td."teamId" = t.id `; const params: any[] = []; if (args.team_id) { if (!isValidUUID(args.team_id)) throw new Error('Invalid team_id format'); query += ` WHERE td."teamId" = $1`; params.push(args.team_id); } query += ` ORDER BY td."createdAt" DESC`; const result = await pgClient.query(query, params); return { content: [{ type: 'text', text: JSON.stringify({ data: result.rows }, null, 2) }], }; }, }; /** * teams.updateSettings - Update team preferences */ const updateTeamSettings: BaseTool<{ id: string; preferences: Record }> = { name: 'outline_update_team_settings', description: 'Update team preferences (JSON settings object).', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Team ID (UUID)' }, preferences: { type: 'object', description: 'Preferences object to merge' }, }, required: ['id', 'preferences'], }, handler: async (args, pgClient): Promise => { if (!isValidUUID(args.id)) throw new Error('Invalid team ID format'); const result = await pgClient.query( `UPDATE teams SET preferences = COALESCE(preferences, '{}'::jsonb) || $1::jsonb, "updatedAt" = NOW() WHERE id = $2 AND "deletedAt" IS NULL RETURNING id, name, preferences`, [JSON.stringify(args.preferences), args.id] ); if (result.rows.length === 0) throw new Error('Team not found'); return { content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Settings updated' }, null, 2) }], }; }, }; export const teamsTools: BaseTool[] = [getTeam, updateTeam, getTeamStats, listTeamDomains, updateTeamSettings];