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>
1333 lines
38 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
},
|
|
},
|
|
];
|