Files
mcp-outline-postgresql/src/tools/collections.ts
Emanuel Almeida 354e8ae21f fix: Schema bugs in create operations - id/urlId columns missing
Fixed 3 schema compatibility bugs found during Round 3 write testing:
- create_document: Added id, urlId, teamId, isWelcome, fullWidth, insightsEnabled
- create_collection: Added id, maintainerApprovalRequired
- shares_create: Added id, allowIndexing, showLastUpdated

All write operations now include required NOT NULL columns.
Bumped version to 1.3.6.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:08:52 +00:00

1333 lines
38 KiB
TypeScript

/**
* MCP Outline PostgreSQL - Collections Tools
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { Pool } from 'pg';
import { BaseTool, ToolResponse } from '../types/tools.js';
import { validatePagination, isValidUUID } from '../utils/security.js';
export const collectionsTools: BaseTool<any>[] = [
// 1. LIST COLLECTIONS
{
name: 'list_collections',
description: 'List all collections in the team. Supports pagination and filtering by teamId.',
inputSchema: {
type: 'object',
properties: {
teamId: {
type: 'string',
description: 'Filter collections by team ID (UUID format)',
},
offset: {
type: 'number',
description: 'Number of records to skip (default: 0)',
default: 0,
},
limit: {
type: 'number',
description: 'Maximum number of records to return (default: 25, max: 100)',
default: 25,
},
},
},
handler: async (args: { teamId?: string; offset?: number; limit?: number }, pool: Pool): Promise<ToolResponse> => {
try {
const { offset = 0, limit = 25, teamId } = args;
validatePagination(offset, limit);
// Note: documentStructure excluded from list (too large) - use get_collection for full details
let query = `
SELECT
c.id,
c."urlId",
c.name,
c.description,
c.icon,
c.color,
c.index,
c.permission,
c."maintainerApprovalRequired",
c.sharing,
c.sort,
c."teamId",
c."createdById",
c."createdAt",
c."updatedAt",
c."deletedAt",
c."archivedAt",
u.name as "createdByName",
u.email as "createdByEmail",
(SELECT COUNT(*) FROM documents WHERE "collectionId" = c.id AND "deletedAt" IS NULL) as "documentCount",
(SELECT COUNT(*) FROM collection_users WHERE "collectionId" = c.id) as "memberCount"
FROM collections c
LEFT JOIN users u ON c."createdById" = u.id
WHERE c."deletedAt" IS NULL
`;
const params: any[] = [];
if (teamId) {
if (!isValidUUID(teamId)) {
return {
content: [{ type: 'text', text: 'Invalid teamId format. Must be a valid UUID.' }],
isError: true,
};
}
params.push(teamId);
query += ` AND c."teamId" = $${params.length}`;
}
query += `
ORDER BY c.index ASC, c."createdAt" DESC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`;
params.push(limit, offset);
const result = await pool.query(query, params);
// Get total count
let countQuery = 'SELECT COUNT(*) FROM collections WHERE "deletedAt" IS NULL';
const countParams: any[] = [];
if (teamId) {
countParams.push(teamId);
countQuery += ` AND "teamId" = $1`;
}
const countResult = await pool.query(countQuery, countParams);
const totalCount = parseInt(countResult.rows[0].count, 10);
return {
content: [
{
type: 'text',
text: JSON.stringify({
collections: result.rows,
pagination: {
total: totalCount,
offset,
limit,
hasMore: offset + limit < totalCount,
},
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error listing collections: ${error.message}` }],
isError: true,
};
}
},
},
// 2. GET COLLECTION
{
name: 'get_collection',
description: 'Get detailed information about a specific collection by ID or urlId.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Collection ID (UUID) or urlId',
},
},
required: ['id'],
},
handler: async (args: { id: string }, pool: Pool): Promise<ToolResponse> => {
try {
const { id } = args;
const query = `
SELECT
c.id,
c."urlId",
c.name,
c.description,
c.icon,
c.color,
c.index,
c.permission,
c."maintainerApprovalRequired",
c."documentStructure",
c.sharing,
c.sort,
c."teamId",
c."createdById",
c."createdAt",
c."updatedAt",
c."deletedAt",
c."archivedAt",
u.name as "createdByName",
u.email as "createdByEmail",
t.name as "teamName",
(SELECT COUNT(*) FROM documents WHERE "collectionId" = c.id AND "deletedAt" IS NULL) as "documentCount",
(SELECT COUNT(*) FROM collection_users WHERE "collectionId" = c.id) as "memberCount",
(SELECT COUNT(*) FROM collection_groups WHERE "collectionId" = c.id) as "groupCount"
FROM collections c
LEFT JOIN users u ON c."createdById" = u.id
LEFT JOIN teams t ON c."teamId" = t.id
WHERE (c.id = $1 OR c."urlId" = $1)
AND c."deletedAt" IS NULL
`;
const result = await pool.query(query, [id]);
if (result.rows.length === 0) {
return {
content: [{ type: 'text', text: 'Collection not found or has been deleted.' }],
isError: true,
};
}
return {
content: [{ type: 'text', text: JSON.stringify(result.rows[0], null, 2) }],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error getting collection: ${error.message}` }],
isError: true,
};
}
},
},
// 3. CREATE COLLECTION
{
name: 'create_collection',
description: 'Create a new collection in a team.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Collection name',
},
teamId: {
type: 'string',
description: 'Team ID (UUID)',
},
createdById: {
type: 'string',
description: 'User ID creating the collection (UUID)',
},
description: {
type: 'string',
description: 'Collection description',
},
icon: {
type: 'string',
description: 'Collection icon (emoji or icon name)',
},
color: {
type: 'string',
description: 'Collection color (hex format)',
},
permission: {
type: 'string',
description: 'Default permission level (read, read_write)',
enum: ['read', 'read_write'],
default: 'read_write',
},
sharing: {
type: 'boolean',
description: 'Allow public sharing',
default: true,
},
index: {
type: 'string',
description: 'Custom index for sorting',
},
},
required: ['name', 'teamId', 'createdById'],
},
handler: async (args: {
name: string;
teamId: string;
createdById: string;
description?: string;
icon?: string;
color?: string;
permission?: string;
sharing?: boolean;
index?: string;
}, pool: Pool): Promise<ToolResponse> => {
try {
const { name, teamId, createdById, description, icon, color, permission = 'read_write', sharing = true, index } = args;
// Validate UUIDs
if (!isValidUUID(teamId) || !isValidUUID(createdById)) {
return {
content: [{ type: 'text', text: 'Invalid teamId or createdById format. Must be valid UUIDs.' }],
isError: true,
};
}
// Generate urlId from name
const urlId = name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 50);
const query = `
INSERT INTO collections (
id, name, "urlId", "teamId", "createdById", description, icon, color,
permission, sharing, "maintainerApprovalRequired", index, "createdAt", "updatedAt"
)
VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, false, $10, NOW(), NOW())
RETURNING
id, "urlId", name, description, icon, color, index, permission,
sharing, "teamId", "createdById", "createdAt", "updatedAt"
`;
const result = await pool.query(query, [
name,
urlId,
teamId,
createdById,
description || null,
icon || null,
color || null,
permission,
sharing,
index || null,
]);
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Collection created successfully',
collection: result.rows[0],
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error creating collection: ${error.message}` }],
isError: true,
};
}
},
},
// 4. UPDATE COLLECTION
{
name: 'update_collection',
description: 'Update an existing collection.',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Collection ID (UUID)',
},
name: {
type: 'string',
description: 'New collection name',
},
description: {
type: 'string',
description: 'New description',
},
icon: {
type: 'string',
description: 'New icon',
},
color: {
type: 'string',
description: 'New color (hex format)',
},
permission: {
type: 'string',
description: 'New default permission',
enum: ['read', 'read_write'],
},
sharing: {
type: 'boolean',
description: 'New sharing setting',
},
index: {
type: 'string',
description: 'New index for sorting',
},
},
required: ['id'],
},
handler: async (args: {
id: string;
name?: string;
description?: string;
icon?: string;
color?: string;
permission?: string;
sharing?: boolean;
index?: string;
}, pool: Pool): Promise<ToolResponse> => {
try {
const { id, name, description, icon, color, permission, sharing, index } = args;
if (!isValidUUID(id)) {
return {
content: [{ type: 'text', text: 'Invalid collection ID format. Must be a valid UUID.' }],
isError: true,
};
}
// Build dynamic UPDATE query
const updates: string[] = [];
const values: any[] = [];
let paramCount = 1;
if (name !== undefined) {
updates.push(`name = $${paramCount}`);
values.push(name);
paramCount++;
// Update urlId if name changes
const urlId = name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.substring(0, 50);
updates.push(`"urlId" = $${paramCount}`);
values.push(urlId);
paramCount++;
}
if (description !== undefined) {
updates.push(`description = $${paramCount}`);
values.push(description);
paramCount++;
}
if (icon !== undefined) {
updates.push(`icon = $${paramCount}`);
values.push(icon);
paramCount++;
}
if (color !== undefined) {
updates.push(`color = $${paramCount}`);
values.push(color);
paramCount++;
}
if (permission !== undefined) {
updates.push(`permission = $${paramCount}`);
values.push(permission);
paramCount++;
}
if (sharing !== undefined) {
updates.push(`sharing = $${paramCount}`);
values.push(sharing);
paramCount++;
}
if (index !== undefined) {
updates.push(`index = $${paramCount}`);
values.push(index);
paramCount++;
}
if (updates.length === 0) {
return {
content: [{ type: 'text', text: 'No fields to update provided.' }],
isError: true,
};
}
updates.push(`"updatedAt" = NOW()`);
values.push(id);
const query = `
UPDATE collections
SET ${updates.join(', ')}
WHERE id = $${paramCount} AND "deletedAt" IS NULL
RETURNING
id, "urlId", name, description, icon, color, index, permission,
sharing, "teamId", "createdById", "createdAt", "updatedAt"
`;
const result = await pool.query(query, values);
if (result.rows.length === 0) {
return {
content: [{ type: 'text', text: 'Collection not found or has been deleted.' }],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Collection updated successfully',
collection: result.rows[0],
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error updating collection: ${error.message}` }],
isError: true,
};
}
},
},
// 5. DELETE COLLECTION
{
name: 'delete_collection',
description: 'Soft delete a collection (sets deletedAt timestamp).',
inputSchema: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Collection ID (UUID)',
},
},
required: ['id'],
},
handler: async (args: { id: string }, pool: Pool): Promise<ToolResponse> => {
try {
const { id } = args;
if (!isValidUUID(id)) {
return {
content: [{ type: 'text', text: 'Invalid collection ID format. Must be a valid UUID.' }],
isError: true,
};
}
const query = `
UPDATE collections
SET "deletedAt" = NOW(), "updatedAt" = NOW()
WHERE id = $1 AND "deletedAt" IS NULL
RETURNING id, name, "deletedAt"
`;
const result = await pool.query(query, [id]);
if (result.rows.length === 0) {
return {
content: [{ type: 'text', text: 'Collection not found or already deleted.' }],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Collection deleted successfully',
collection: result.rows[0],
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error deleting collection: ${error.message}` }],
isError: true,
};
}
},
},
// 6. LIST COLLECTION DOCUMENTS
{
name: 'list_collection_documents',
description: 'List all documents in a collection with pagination.',
inputSchema: {
type: 'object',
properties: {
collectionId: {
type: 'string',
description: 'Collection ID (UUID)',
},
offset: {
type: 'number',
description: 'Number of records to skip (default: 0)',
default: 0,
},
limit: {
type: 'number',
description: 'Maximum number of records to return (default: 25, max: 100)',
default: 25,
},
},
required: ['collectionId'],
},
handler: async (args: { collectionId: string; offset?: number; limit?: number }, pool: Pool): Promise<ToolResponse> => {
try {
const { collectionId, offset = 0, limit = 25 } = args;
if (!isValidUUID(collectionId)) {
return {
content: [{ type: 'text', text: 'Invalid collectionId format. Must be a valid UUID.' }],
isError: true,
};
}
validatePagination(offset, limit);
const query = `
SELECT
d.id,
d."urlId",
d.title,
d.icon,
d."collectionId",
d."parentDocumentId",
d.template,
d.fullWidth,
d."createdById",
d."lastModifiedById",
d."createdAt",
d."updatedAt",
d."publishedAt",
d."archivedAt",
d."deletedAt",
creator.name as "createdByName",
creator.email as "createdByEmail",
updater.name as "updatedByName",
updater.email as "updatedByEmail"
FROM documents d
LEFT JOIN users creator ON d."createdById" = creator.id
LEFT JOIN users updater ON d."lastModifiedById" = updater.id
WHERE d."collectionId" = $1 AND d."deletedAt" IS NULL
ORDER BY d."updatedAt" DESC
LIMIT $2 OFFSET $3
`;
const result = await pool.query(query, [collectionId, limit, offset]);
// Get total count
const countQuery = 'SELECT COUNT(*) FROM documents WHERE "collectionId" = $1 AND "deletedAt" IS NULL';
const countResult = await pool.query(countQuery, [collectionId]);
const totalCount = parseInt(countResult.rows[0].count, 10);
return {
content: [
{
type: 'text',
text: JSON.stringify({
documents: result.rows,
pagination: {
total: totalCount,
offset,
limit,
hasMore: offset + limit < totalCount,
},
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error listing collection documents: ${error.message}` }],
isError: true,
};
}
},
},
// 7. ADD USER TO COLLECTION
{
name: 'add_user_to_collection',
description: 'Add a user to a collection with specific permissions.',
inputSchema: {
type: 'object',
properties: {
collectionId: {
type: 'string',
description: 'Collection ID (UUID)',
},
userId: {
type: 'string',
description: 'User ID (UUID)',
},
permission: {
type: 'string',
description: 'Permission level for the user',
enum: ['read', 'read_write', 'maintain'],
default: 'read_write',
},
createdById: {
type: 'string',
description: 'User ID adding the member (UUID)',
},
},
required: ['collectionId', 'userId', 'createdById'],
},
handler: async (args: {
collectionId: string;
userId: string;
permission?: string;
createdById: string;
}, pool: Pool): Promise<ToolResponse> => {
try {
const { collectionId, userId, permission = 'read_write', createdById } = args;
if (!isValidUUID(collectionId) || !isValidUUID(userId) || !isValidUUID(createdById)) {
return {
content: [{ type: 'text', text: 'Invalid UUID format for collectionId, userId, or createdById.' }],
isError: true,
};
}
const query = `
INSERT INTO collection_users ("collectionId", "userId", permission, "createdById", "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT ("collectionId", "userId")
DO UPDATE SET permission = $3, "updatedAt" = NOW()
RETURNING
id, "collectionId", "userId", permission, "createdById", "createdAt", "updatedAt"
`;
const result = await pool.query(query, [collectionId, userId, permission, createdById]);
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'User added to collection successfully',
membership: result.rows[0],
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error adding user to collection: ${error.message}` }],
isError: true,
};
}
},
},
// 8. REMOVE USER FROM COLLECTION
{
name: 'remove_user_from_collection',
description: 'Remove a user from a collection.',
inputSchema: {
type: 'object',
properties: {
collectionId: {
type: 'string',
description: 'Collection ID (UUID)',
},
userId: {
type: 'string',
description: 'User ID (UUID)',
},
},
required: ['collectionId', 'userId'],
},
handler: async (args: { collectionId: string; userId: string }, pool: Pool): Promise<ToolResponse> => {
try {
const { collectionId, userId } = args;
if (!isValidUUID(collectionId) || !isValidUUID(userId)) {
return {
content: [{ type: 'text', text: 'Invalid UUID format for collectionId or userId.' }],
isError: true,
};
}
const query = `
DELETE FROM collection_users
WHERE "collectionId" = $1 AND "userId" = $2
RETURNING id, "collectionId", "userId"
`;
const result = await pool.query(query, [collectionId, userId]);
if (result.rows.length === 0) {
return {
content: [{ type: 'text', text: 'User membership not found in collection.' }],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'User removed from collection successfully',
membership: result.rows[0],
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error removing user from collection: ${error.message}` }],
isError: true,
};
}
},
},
// 9. LIST COLLECTION MEMBERSHIPS
{
name: 'list_collection_memberships',
description: 'List all user memberships for a collection.',
inputSchema: {
type: 'object',
properties: {
collectionId: {
type: 'string',
description: 'Collection ID (UUID)',
},
offset: {
type: 'number',
description: 'Number of records to skip (default: 0)',
default: 0,
},
limit: {
type: 'number',
description: 'Maximum number of records to return (default: 25, max: 100)',
default: 25,
},
},
required: ['collectionId'],
},
handler: async (args: { collectionId: string; offset?: number; limit?: number }, pool: Pool): Promise<ToolResponse> => {
try {
const { collectionId, offset = 0, limit = 25 } = args;
if (!isValidUUID(collectionId)) {
return {
content: [{ type: 'text', text: 'Invalid collectionId format. Must be a valid UUID.' }],
isError: true,
};
}
validatePagination(offset, limit);
const query = `
SELECT
cu.id,
cu."collectionId",
cu."userId",
cu.permission,
cu."createdById",
cu."createdAt",
cu."updatedAt",
u.name as "userName",
u.email as "userEmail",
creator.name as "addedByName"
FROM collection_users cu
LEFT JOIN users u ON cu."userId" = u.id
LEFT JOIN users creator ON cu."createdById" = creator.id
WHERE cu."collectionId" = $1
ORDER BY cu."createdAt" DESC
LIMIT $2 OFFSET $3
`;
const result = await pool.query(query, [collectionId, limit, offset]);
// Get total count
const countQuery = 'SELECT COUNT(*) FROM collection_users WHERE "collectionId" = $1';
const countResult = await pool.query(countQuery, [collectionId]);
const totalCount = parseInt(countResult.rows[0].count, 10);
return {
content: [
{
type: 'text',
text: JSON.stringify({
memberships: result.rows,
pagination: {
total: totalCount,
offset,
limit,
hasMore: offset + limit < totalCount,
},
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error listing collection memberships: ${error.message}` }],
isError: true,
};
}
},
},
// 10. ADD GROUP TO COLLECTION
{
name: 'add_group_to_collection',
description: 'Add a group to a collection with specific permissions.',
inputSchema: {
type: 'object',
properties: {
collectionId: {
type: 'string',
description: 'Collection ID (UUID)',
},
groupId: {
type: 'string',
description: 'Group ID (UUID)',
},
permission: {
type: 'string',
description: 'Permission level for the group',
enum: ['read', 'read_write', 'maintain'],
default: 'read_write',
},
createdById: {
type: 'string',
description: 'User ID adding the group (UUID)',
},
},
required: ['collectionId', 'groupId', 'createdById'],
},
handler: async (args: {
collectionId: string;
groupId: string;
permission?: string;
createdById: string;
}, pool: Pool): Promise<ToolResponse> => {
try {
const { collectionId, groupId, permission = 'read_write', createdById } = args;
if (!isValidUUID(collectionId) || !isValidUUID(groupId) || !isValidUUID(createdById)) {
return {
content: [{ type: 'text', text: 'Invalid UUID format for collectionId, groupId, or createdById.' }],
isError: true,
};
}
const query = `
INSERT INTO collection_groups ("collectionId", "groupId", permission, "createdById", "createdAt", "updatedAt")
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT ("collectionId", "groupId")
DO UPDATE SET permission = $3, "updatedAt" = NOW()
RETURNING
id, "collectionId", "groupId", permission, "createdById", "createdAt", "updatedAt"
`;
const result = await pool.query(query, [collectionId, groupId, permission, createdById]);
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Group added to collection successfully',
membership: result.rows[0],
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error adding group to collection: ${error.message}` }],
isError: true,
};
}
},
},
// 11. REMOVE GROUP FROM COLLECTION
{
name: 'remove_group_from_collection',
description: 'Remove a group from a collection.',
inputSchema: {
type: 'object',
properties: {
collectionId: {
type: 'string',
description: 'Collection ID (UUID)',
},
groupId: {
type: 'string',
description: 'Group ID (UUID)',
},
},
required: ['collectionId', 'groupId'],
},
handler: async (args: { collectionId: string; groupId: string }, pool: Pool): Promise<ToolResponse> => {
try {
const { collectionId, groupId } = args;
if (!isValidUUID(collectionId) || !isValidUUID(groupId)) {
return {
content: [{ type: 'text', text: 'Invalid UUID format for collectionId or groupId.' }],
isError: true,
};
}
const query = `
DELETE FROM collection_groups
WHERE "collectionId" = $1 AND "groupId" = $2
RETURNING id, "collectionId", "groupId"
`;
const result = await pool.query(query, [collectionId, groupId]);
if (result.rows.length === 0) {
return {
content: [{ type: 'text', text: 'Group membership not found in collection.' }],
isError: true,
};
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
message: 'Group removed from collection successfully',
membership: result.rows[0],
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error removing group from collection: ${error.message}` }],
isError: true,
};
}
},
},
// 12. LIST COLLECTION GROUP MEMBERSHIPS
{
name: 'list_collection_group_memberships',
description: 'List all group memberships for a collection.',
inputSchema: {
type: 'object',
properties: {
collectionId: {
type: 'string',
description: 'Collection ID (UUID)',
},
offset: {
type: 'number',
description: 'Number of records to skip (default: 0)',
default: 0,
},
limit: {
type: 'number',
description: 'Maximum number of records to return (default: 25, max: 100)',
default: 25,
},
},
required: ['collectionId'],
},
handler: async (args: { collectionId: string; offset?: number; limit?: number }, pool: Pool): Promise<ToolResponse> => {
try {
const { collectionId, offset = 0, limit = 25 } = args;
if (!isValidUUID(collectionId)) {
return {
content: [{ type: 'text', text: 'Invalid collectionId format. Must be a valid UUID.' }],
isError: true,
};
}
validatePagination(offset, limit);
const query = `
SELECT
cg.id,
cg."collectionId",
cg."groupId",
cg.permission,
cg."createdById",
cg."createdAt",
cg."updatedAt",
g.name as "groupName",
creator.name as "addedByName"
FROM collection_groups cg
LEFT JOIN groups g ON cg."groupId" = g.id
LEFT JOIN users creator ON cg."createdById" = creator.id
WHERE cg."collectionId" = $1
ORDER BY cg."createdAt" DESC
LIMIT $2 OFFSET $3
`;
const result = await pool.query(query, [collectionId, limit, offset]);
// Get total count
const countQuery = 'SELECT COUNT(*) FROM collection_groups WHERE "collectionId" = $1';
const countResult = await pool.query(countQuery, [collectionId]);
const totalCount = parseInt(countResult.rows[0].count, 10);
return {
content: [
{
type: 'text',
text: JSON.stringify({
memberships: result.rows,
pagination: {
total: totalCount,
offset,
limit,
hasMore: offset + limit < totalCount,
},
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error listing collection group memberships: ${error.message}` }],
isError: true,
};
}
},
},
// 13. EXPORT COLLECTION
{
name: 'export_collection',
description: 'Export all documents from a collection as markdown files.',
inputSchema: {
type: 'object',
properties: {
collectionId: {
type: 'string',
description: 'Collection ID (UUID)',
},
includeArchived: {
type: 'boolean',
description: 'Include archived documents',
default: false,
},
},
required: ['collectionId'],
},
handler: async (args: { collectionId: string; includeArchived?: boolean }, pool: Pool): Promise<ToolResponse> => {
try {
const { collectionId, includeArchived = false } = args;
if (!isValidUUID(collectionId)) {
return {
content: [{ type: 'text', text: 'Invalid collectionId format. Must be a valid UUID.' }],
isError: true,
};
}
// Get collection info
const collectionQuery = `
SELECT id, name, description
FROM collections
WHERE id = $1 AND "deletedAt" IS NULL
`;
const collectionResult = await pool.query(collectionQuery, [collectionId]);
if (collectionResult.rows.length === 0) {
return {
content: [{ type: 'text', text: 'Collection not found or has been deleted.' }],
isError: true,
};
}
const collection = collectionResult.rows[0];
// Get documents
let documentsQuery = `
SELECT
d.id,
d.title,
d.icon,
d.text,
d."createdAt",
d."updatedAt",
d."publishedAt",
u.name as "authorName"
FROM documents d
LEFT JOIN users u ON d."createdById" = u.id
WHERE d."collectionId" = $1 AND d."deletedAt" IS NULL
`;
if (!includeArchived) {
documentsQuery += ` AND d."archivedAt" IS NULL`;
}
documentsQuery += ` ORDER BY d."createdAt" ASC`;
const documentsResult = await pool.query(documentsQuery, [collectionId]);
// Format export
const exports = documentsResult.rows.map(doc => {
const markdown = `---
title: ${doc.title}
icon: ${doc.icon || ''}
author: ${doc.authorName}
created: ${doc.createdAt}
updated: ${doc.updatedAt}
published: ${doc.publishedAt || 'Not published'}
---
${doc.text || ''}
`;
return {
id: doc.id,
title: doc.title,
filename: `${doc.title.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.md`,
markdown,
};
});
return {
content: [
{
type: 'text',
text: JSON.stringify({
collection: {
id: collection.id,
name: collection.name,
description: collection.description,
},
documentCount: exports.length,
documents: exports,
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error exporting collection: ${error.message}` }],
isError: true,
};
}
},
},
// 14. EXPORT ALL COLLECTIONS
{
name: 'export_all_collections',
description: 'Export all collections and their documents from a team.',
inputSchema: {
type: 'object',
properties: {
teamId: {
type: 'string',
description: 'Team ID (UUID)',
},
includeArchived: {
type: 'boolean',
description: 'Include archived documents',
default: false,
},
},
required: ['teamId'],
},
handler: async (args: { teamId: string; includeArchived?: boolean }, pool: Pool): Promise<ToolResponse> => {
try {
const { teamId, includeArchived = false } = args;
if (!isValidUUID(teamId)) {
return {
content: [{ type: 'text', text: 'Invalid teamId format. Must be a valid UUID.' }],
isError: true,
};
}
// Get all collections
const collectionsQuery = `
SELECT id, name, description
FROM collections
WHERE "teamId" = $1 AND "deletedAt" IS NULL
ORDER BY index ASC, "createdAt" ASC
`;
const collectionsResult = await pool.query(collectionsQuery, [teamId]);
const exports = [];
for (const collection of collectionsResult.rows) {
// Get documents for each collection
let documentsQuery = `
SELECT
d.id,
d.title,
d.icon,
d.text,
d."createdAt",
d."updatedAt",
d."publishedAt",
u.name as "authorName"
FROM documents d
LEFT JOIN users u ON d."createdById" = u.id
WHERE d."collectionId" = $1 AND d."deletedAt" IS NULL
`;
if (!includeArchived) {
documentsQuery += ` AND d."archivedAt" IS NULL`;
}
documentsQuery += ` ORDER BY d."createdAt" ASC`;
const documentsResult = await pool.query(documentsQuery, [collection.id]);
const documents = documentsResult.rows.map(doc => {
const markdown = `---
title: ${doc.title}
icon: ${doc.icon || ''}
author: ${doc.authorName}
created: ${doc.createdAt}
updated: ${doc.updatedAt}
published: ${doc.publishedAt || 'Not published'}
---
${doc.text || ''}
`;
return {
id: doc.id,
title: doc.title,
filename: `${doc.title.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}.md`,
markdown,
};
});
exports.push({
collection: {
id: collection.id,
name: collection.name,
description: collection.description,
},
documentCount: documents.length,
documents,
});
}
return {
content: [
{
type: 'text',
text: JSON.stringify({
teamId,
collectionCount: exports.length,
totalDocuments: exports.reduce((sum, col) => sum + col.documentCount, 0),
collections: exports,
}, null, 2),
},
],
};
} catch (error: any) {
return {
content: [{ type: 'text', text: `Error exporting all collections: ${error.message}` }],
isError: true,
};
}
},
},
];