feat: Add export/import and Desk CRM sync tools (164 total)

New modules:
- export-import.ts (2 tools): export_collection_to_markdown, import_markdown_folder
- desk-sync.ts (2 tools): create_desk_project_doc, link_desk_task

Updated:
- CHANGELOG.md: Version 1.2.1
- CLAUDE.md: Updated to 164 tools across 33 modules
- CONTINUE.md: Updated state documentation
- AUDIT-REQUEST.md: Updated metrics and file list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-31 14:24:05 +00:00
parent 83b70f557e
commit 7895f31394
8 changed files with 820 additions and 8 deletions

View File

@@ -53,7 +53,9 @@ import {
userPermissionsTools,
bulkOperationsTools,
advancedSearchTools,
analyticsTools
analyticsTools,
exportImportTools,
deskSyncTools
} from './tools/index.js';
dotenv.config();
@@ -113,7 +115,11 @@ const allTools: BaseTool[] = [
// Permissions & Bulk operations
...userPermissionsTools,
...bulkOperationsTools
...bulkOperationsTools,
// Export/Import & External Sync
...exportImportTools,
...deskSyncTools
];
// Validate all tools have required properties
@@ -259,7 +265,9 @@ async function main() {
userPermissions: userPermissionsTools.length,
bulkOperations: bulkOperationsTools.length,
advancedSearch: advancedSearchTools.length,
analytics: analyticsTools.length
analytics: analyticsTools.length,
exportImport: exportImportTools.length,
deskSync: deskSyncTools.length
}
});
}

330
src/tools/desk-sync.ts Normal file
View File

@@ -0,0 +1,330 @@
/**
* MCP Outline PostgreSQL - Desk CRM Sync Tools
* Integration with Desk CRM for project documentation
* @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';
interface CreateDeskProjectDocArgs {
collection_id: string;
desk_project_id: number;
desk_project_name: string;
desk_project_description?: string;
desk_customer_name?: string;
template_id?: string;
include_tasks?: boolean;
tasks?: Array<{
id: number;
name: string;
status: string;
assignees?: string[];
}>;
}
interface LinkDeskTaskArgs {
document_id: string;
desk_task_id: number;
desk_task_name: string;
desk_project_id?: number;
link_type?: 'reference' | 'sync';
sync_status?: boolean;
}
/**
* desk_sync.create_project_doc - Create Outline doc from Desk project
*/
const createDeskProjectDoc: BaseTool<CreateDeskProjectDocArgs> = {
name: 'outline_create_desk_project_doc',
description: 'Create a documentation document in Outline from a Desk CRM project. Includes project info and optionally tasks.',
inputSchema: {
type: 'object',
properties: {
collection_id: { type: 'string', description: 'Target collection ID (UUID)' },
desk_project_id: { type: 'number', description: 'Desk CRM project ID' },
desk_project_name: { type: 'string', description: 'Project name from Desk' },
desk_project_description: { type: 'string', description: 'Project description' },
desk_customer_name: { type: 'string', description: 'Customer name from Desk' },
template_id: { type: 'string', description: 'Optional template ID to base document on (UUID)' },
include_tasks: { type: 'boolean', description: 'Include task list in document (default: true)' },
tasks: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number' },
name: { type: 'string' },
status: { type: 'string' },
assignees: { type: 'array', items: { type: 'string' } },
},
},
description: 'Tasks from Desk to include',
},
},
required: ['collection_id', 'desk_project_id', 'desk_project_name'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
if (args.template_id && !isValidUUID(args.template_id)) throw new Error('Invalid template_id');
// Verify collection exists
const collection = await pgClient.query(
`SELECT id, "teamId" FROM collections WHERE id = $1 AND "deletedAt" IS NULL`,
[args.collection_id]
);
if (collection.rows.length === 0) throw new Error('Collection not found');
const teamId = collection.rows[0].teamId;
// Get admin user
const userResult = await pgClient.query(
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
);
if (userResult.rows.length === 0) throw new Error('No admin user found');
const userId = userResult.rows[0].id;
// Get template content if specified
let baseContent = '';
if (args.template_id) {
const template = await pgClient.query(
`SELECT text FROM documents WHERE id = $1 AND template = true AND "deletedAt" IS NULL`,
[args.template_id]
);
if (template.rows.length > 0) {
baseContent = template.rows[0].text || '';
}
}
// Build document content
const includeTasks = args.include_tasks !== false;
const projectName = sanitizeInput(args.desk_project_name);
const customerName = args.desk_customer_name ? sanitizeInput(args.desk_customer_name) : null;
let content = baseContent || '';
// Add project header if no template
if (!args.template_id) {
content = `## Informações do Projecto\n\n`;
content += `| Campo | Valor |\n`;
content += `|-------|-------|\n`;
content += `| **ID Desk** | #${args.desk_project_id} |\n`;
content += `| **Nome** | ${projectName} |\n`;
if (customerName) {
content += `| **Cliente** | ${customerName} |\n`;
}
content += `| **Criado em** | ${new Date().toISOString().split('T')[0]} |\n`;
content += `\n`;
if (args.desk_project_description) {
content += `## Descrição\n\n${sanitizeInput(args.desk_project_description)}\n\n`;
}
}
// Add tasks section
if (includeTasks && args.tasks && args.tasks.length > 0) {
content += `## Tarefas\n\n`;
content += `| ID | Tarefa | Estado | Responsável |\n`;
content += `|----|--------|--------|-------------|\n`;
for (const task of args.tasks) {
const assignees = task.assignees?.join(', ') || '-';
const statusEmoji = task.status === 'complete' ? '✅' : task.status === 'in_progress' ? '🔄' : '⬜';
content += `| #${task.id} | ${sanitizeInput(task.name)} | ${statusEmoji} ${task.status} | ${assignees} |\n`;
}
content += `\n`;
}
// Add sync metadata section
content += `---\n\n`;
content += `> **Desk Sync:** Este documento está vinculado ao projecto Desk #${args.desk_project_id}\n`;
content += `> Última sincronização: ${new Date().toISOString()}\n`;
// Create document
const result = await pgClient.query(`
INSERT INTO documents (
id, title, text, emoji, "collectionId", "teamId",
"createdById", "lastModifiedById", template,
"createdAt", "updatedAt"
)
VALUES (
gen_random_uuid(), $1, $2, '📋', $3, $4, $5, $5, false, NOW(), NOW()
)
RETURNING id, title, "createdAt"
`, [
projectName,
content,
args.collection_id,
teamId,
userId,
]);
const newDoc = result.rows[0];
// Store Desk reference in document metadata (using a comment as metadata storage)
await pgClient.query(`
INSERT INTO comments (id, "documentId", "createdById", data, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, NOW(), NOW())
`, [
newDoc.id,
userId,
JSON.stringify({
type: 'desk_sync_metadata',
desk_project_id: args.desk_project_id,
desk_customer_name: customerName,
synced_at: new Date().toISOString(),
}),
]);
return {
content: [{ type: 'text', text: JSON.stringify({
document: {
id: newDoc.id,
title: newDoc.title,
createdAt: newDoc.createdAt,
},
deskProject: {
id: args.desk_project_id,
name: projectName,
customer: customerName,
},
tasksIncluded: includeTasks ? (args.tasks?.length || 0) : 0,
message: `Created documentation for Desk project #${args.desk_project_id}`,
}, null, 2) }],
};
},
};
/**
* desk_sync.link_task - Link Desk task to Outline document
*/
const linkDeskTask: BaseTool<LinkDeskTaskArgs> = {
name: 'outline_link_desk_task',
description: 'Link a Desk CRM task to an Outline document for cross-reference and optional sync.',
inputSchema: {
type: 'object',
properties: {
document_id: { type: 'string', description: 'Outline document ID (UUID)' },
desk_task_id: { type: 'number', description: 'Desk CRM task ID' },
desk_task_name: { type: 'string', description: 'Task name from Desk' },
desk_project_id: { type: 'number', description: 'Desk CRM project ID (optional)' },
link_type: { type: 'string', enum: ['reference', 'sync'], description: 'Link type: reference (one-way) or sync (two-way) (default: reference)' },
sync_status: { type: 'boolean', description: 'Sync task status changes (default: false)' },
},
required: ['document_id', 'desk_task_id', 'desk_task_name'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
// Verify document exists
const document = await pgClient.query(
`SELECT id, title, text FROM documents WHERE id = $1 AND "deletedAt" IS NULL`,
[args.document_id]
);
if (document.rows.length === 0) throw new Error('Document not found');
const doc = document.rows[0];
const linkType = args.link_type || 'reference';
const taskName = sanitizeInput(args.desk_task_name);
// Get admin user
const userResult = await pgClient.query(
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
);
const userId = userResult.rows.length > 0 ? userResult.rows[0].id : null;
// Check if link already exists (search in comments)
const existingLink = await pgClient.query(`
SELECT id FROM comments
WHERE "documentId" = $1
AND data::text LIKE $2
`, [args.document_id, `%"desk_task_id":${args.desk_task_id}%`]);
if (existingLink.rows.length > 0) {
// Update existing link
await pgClient.query(`
UPDATE comments
SET data = $1, "updatedAt" = NOW()
WHERE id = $2
`, [
JSON.stringify({
type: 'desk_task_link',
desk_task_id: args.desk_task_id,
desk_task_name: taskName,
desk_project_id: args.desk_project_id || null,
link_type: linkType,
sync_status: args.sync_status || false,
updated_at: new Date().toISOString(),
}),
existingLink.rows[0].id,
]);
return {
content: [{ type: 'text', text: JSON.stringify({
action: 'updated',
documentId: args.document_id,
documentTitle: doc.title,
deskTask: {
id: args.desk_task_id,
name: taskName,
projectId: args.desk_project_id,
},
linkType,
syncStatus: args.sync_status || false,
message: `Updated link to Desk task #${args.desk_task_id}`,
}, null, 2) }],
};
}
// Create new link
await pgClient.query(`
INSERT INTO comments (id, "documentId", "createdById", data, "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2, $3, NOW(), NOW())
`, [
args.document_id,
userId,
JSON.stringify({
type: 'desk_task_link',
desk_task_id: args.desk_task_id,
desk_task_name: taskName,
desk_project_id: args.desk_project_id || null,
link_type: linkType,
sync_status: args.sync_status || false,
created_at: new Date().toISOString(),
}),
]);
// Optionally append reference to document text
if (linkType === 'reference') {
const refText = `\n\n---\n> 🔗 **Tarefa Desk:** #${args.desk_task_id} - ${taskName}`;
// Only append if not already present
if (!doc.text?.includes(`#${args.desk_task_id}`)) {
await pgClient.query(`
UPDATE documents
SET text = text || $1, "updatedAt" = NOW()
WHERE id = $2
`, [refText, args.document_id]);
}
}
return {
content: [{ type: 'text', text: JSON.stringify({
action: 'created',
documentId: args.document_id,
documentTitle: doc.title,
deskTask: {
id: args.desk_task_id,
name: taskName,
projectId: args.desk_project_id,
},
linkType,
syncStatus: args.sync_status || false,
message: `Linked Desk task #${args.desk_task_id} to document "${doc.title}"`,
}, null, 2) }],
};
},
};
export const deskSyncTools: BaseTool<any>[] = [createDeskProjectDoc, linkDeskTask];

304
src/tools/export-import.ts Normal file
View File

@@ -0,0 +1,304 @@
/**
* MCP Outline PostgreSQL - Export/Import Tools
* Advanced export to Markdown and import from Markdown folders
* @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';
interface ExportCollectionArgs {
collection_id: string;
include_children?: boolean;
include_metadata?: boolean;
format?: 'markdown' | 'json';
}
interface ImportMarkdownArgs {
collection_id: string;
documents: Array<{
title: string;
content: string;
parent_path?: string;
emoji?: string;
}>;
create_hierarchy?: boolean;
}
/**
* export.collection_to_markdown - Export entire collection to Markdown
*/
const exportCollectionToMarkdown: BaseTool<ExportCollectionArgs> = {
name: 'outline_export_collection_to_markdown',
description: 'Export an entire collection to Markdown format with document hierarchy.',
inputSchema: {
type: 'object',
properties: {
collection_id: { type: 'string', description: 'Collection ID (UUID)' },
include_children: { type: 'boolean', description: 'Include nested documents (default: true)' },
include_metadata: { type: 'boolean', description: 'Include YAML frontmatter with metadata (default: true)' },
format: { type: 'string', enum: ['markdown', 'json'], description: 'Output format (default: markdown)' },
},
required: ['collection_id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
const includeChildren = args.include_children !== false;
const includeMetadata = args.include_metadata !== false;
const format = args.format || 'markdown';
// Get collection info
const collection = await pgClient.query(`
SELECT id, name, description, icon, color
FROM collections
WHERE id = $1 AND "deletedAt" IS NULL
`, [args.collection_id]);
if (collection.rows.length === 0) throw new Error('Collection not found');
// Get all documents in collection
const documents = await pgClient.query(`
WITH RECURSIVE doc_tree AS (
SELECT
d.id, d.title, d.text, d.emoji, d."parentDocumentId",
d."createdAt", d."updatedAt", d."publishedAt",
u.name as "authorName",
0 as depth,
d.title as path
FROM documents d
LEFT JOIN users u ON d."createdById" = u.id
WHERE d."collectionId" = $1
AND d."parentDocumentId" IS NULL
AND d."deletedAt" IS NULL
AND d.template = false
UNION ALL
SELECT
d.id, d.title, d.text, d.emoji, d."parentDocumentId",
d."createdAt", d."updatedAt", d."publishedAt",
u.name as "authorName",
dt.depth + 1,
dt.path || '/' || d.title
FROM documents d
LEFT JOIN users u ON d."createdById" = u.id
JOIN doc_tree dt ON d."parentDocumentId" = dt.id
WHERE d."deletedAt" IS NULL AND d.template = false
)
SELECT * FROM doc_tree
ORDER BY path
`, [args.collection_id]);
if (format === 'json') {
return {
content: [{ type: 'text', text: JSON.stringify({
collection: collection.rows[0],
documents: documents.rows,
exportedAt: new Date().toISOString(),
totalDocuments: documents.rows.length,
}, null, 2) }],
};
}
// Build Markdown output
const markdownFiles: Array<{ path: string; content: string }> = [];
for (const doc of documents.rows) {
let content = '';
if (includeMetadata) {
content += '---\n';
content += `title: "${doc.title.replace(/"/g, '\\"')}"\n`;
if (doc.emoji) content += `emoji: "${doc.emoji}"\n`;
content += `author: "${doc.authorName || 'Unknown'}"\n`;
content += `created: ${doc.createdAt}\n`;
content += `updated: ${doc.updatedAt}\n`;
if (doc.publishedAt) content += `published: ${doc.publishedAt}\n`;
content += `outline_id: ${doc.id}\n`;
content += '---\n\n';
}
// Add title as H1 if not already in content
if (!doc.text?.startsWith('# ')) {
content += `# ${doc.emoji ? doc.emoji + ' ' : ''}${doc.title}\n\n`;
}
content += doc.text || '';
const fileName = doc.path
.replace(/[^a-zA-Z0-9\/\-_\s]/g, '')
.replace(/\s+/g, '-')
.toLowerCase();
markdownFiles.push({
path: `${fileName}.md`,
content,
});
}
return {
content: [{ type: 'text', text: JSON.stringify({
collection: {
name: collection.rows[0].name,
description: collection.rows[0].description,
},
files: markdownFiles,
exportedAt: new Date().toISOString(),
totalFiles: markdownFiles.length,
message: `Exported ${markdownFiles.length} documents from collection "${collection.rows[0].name}"`,
}, null, 2) }],
};
},
};
/**
* import.markdown_folder - Import Markdown documents into collection
*/
const importMarkdownFolder: BaseTool<ImportMarkdownArgs> = {
name: 'outline_import_markdown_folder',
description: 'Import multiple Markdown documents into a collection, preserving hierarchy.',
inputSchema: {
type: 'object',
properties: {
collection_id: { type: 'string', description: 'Target collection ID (UUID)' },
documents: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string', description: 'Document title' },
content: { type: 'string', description: 'Markdown content' },
parent_path: { type: 'string', description: 'Parent document path (e.g., "parent/child")' },
emoji: { type: 'string', description: 'Document emoji' },
},
required: ['title', 'content'],
},
description: 'Array of documents to import',
},
create_hierarchy: { type: 'boolean', description: 'Create parent documents if they don\'t exist (default: true)' },
},
required: ['collection_id', 'documents'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
if (!args.documents || args.documents.length === 0) throw new Error('At least one document required');
if (args.documents.length > 100) throw new Error('Maximum 100 documents per import');
const createHierarchy = args.create_hierarchy !== false;
// Verify collection exists
const collection = await pgClient.query(
`SELECT id, "teamId" FROM collections WHERE id = $1 AND "deletedAt" IS NULL`,
[args.collection_id]
);
if (collection.rows.length === 0) throw new Error('Collection not found');
const teamId = collection.rows[0].teamId;
// Get admin user for createdById
const userResult = await pgClient.query(
`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`
);
if (userResult.rows.length === 0) throw new Error('No admin user found');
const userId = userResult.rows[0].id;
const imported: Array<{ id: string; title: string; path: string }> = [];
const errors: Array<{ title: string; error: string }> = [];
const pathToId: Record<string, string> = {};
// First pass: create all documents (sorted by path depth)
const sortedDocs = [...args.documents].sort((a, b) => {
const depthA = (a.parent_path || '').split('/').filter(Boolean).length;
const depthB = (b.parent_path || '').split('/').filter(Boolean).length;
return depthA - depthB;
});
for (const doc of sortedDocs) {
try {
let parentDocumentId: string | null = null;
// Resolve parent if specified
if (doc.parent_path && createHierarchy) {
const parentPath = doc.parent_path.trim();
if (pathToId[parentPath]) {
parentDocumentId = pathToId[parentPath];
} else {
// Try to find existing parent by title
const parentTitle = parentPath.split('/').pop();
const existingParent = await pgClient.query(
`SELECT id FROM documents WHERE title = $1 AND "collectionId" = $2 AND "deletedAt" IS NULL LIMIT 1`,
[parentTitle, args.collection_id]
);
if (existingParent.rows.length > 0) {
parentDocumentId = existingParent.rows[0].id;
if (parentDocumentId) {
pathToId[parentPath] = parentDocumentId;
}
}
}
}
// Strip YAML frontmatter if present
let content = doc.content;
if (content.startsWith('---')) {
const endOfFrontmatter = content.indexOf('---', 3);
if (endOfFrontmatter !== -1) {
content = content.substring(endOfFrontmatter + 3).trim();
}
}
// Create document
const result = await pgClient.query(`
INSERT INTO documents (
id, title, text, emoji, "collectionId", "teamId", "parentDocumentId",
"createdById", "lastModifiedById", template, "createdAt", "updatedAt"
)
VALUES (
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $7, false, NOW(), NOW()
)
RETURNING id, title
`, [
sanitizeInput(doc.title),
content,
doc.emoji || null,
args.collection_id,
teamId,
parentDocumentId,
userId,
]);
const newDoc = result.rows[0];
const fullPath = doc.parent_path ? `${doc.parent_path}/${doc.title}` : doc.title;
pathToId[fullPath] = newDoc.id;
imported.push({
id: newDoc.id,
title: newDoc.title,
path: fullPath,
});
} catch (error) {
errors.push({
title: doc.title,
error: error instanceof Error ? error.message : String(error),
});
}
}
return {
content: [{ type: 'text', text: JSON.stringify({
imported,
errors,
importedCount: imported.length,
errorCount: errors.length,
collectionId: args.collection_id,
message: `Imported ${imported.length} documents${errors.length > 0 ? `, ${errors.length} failed` : ''}`,
}, null, 2) }],
};
},
};
export const exportImportTools: BaseTool<any>[] = [exportCollectionToMarkdown, importMarkdownFolder];

View File

@@ -96,3 +96,9 @@ export { advancedSearchTools } from './advanced-search.js';
// Analytics Tools - Usage statistics and insights
export { analyticsTools } from './analytics.js';
// Export/Import Tools - Advanced Markdown export/import
export { exportImportTools } from './export-import.js';
// Desk Sync Tools - Desk CRM integration
export { deskSyncTools } from './desk-sync.js';