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