Files
mcp-outline-postgresql/src/tools/templates.ts
Emanuel Almeida 7d2a014b74 fix: Schema compatibility - emoji → icon column rename
Production Outline DB uses 'icon' column instead of 'emoji' for documents
and revisions. Fixed all affected queries:

- documents.ts: SELECT queries
- advanced-search.ts: Search queries
- analytics.ts: Analytics + GROUP BY
- export-import.ts: Export/import metadata
- templates.ts: Template queries + INSERT
- collections.ts: Collection document listing
- revisions.ts: Revision comparison

reactions.emoji kept unchanged (correct schema)

Tested: 448 documents successfully queried from hub.descomplicar.pt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 17:14:27 +00:00

224 lines
7.8 KiB
TypeScript

/**
* MCP Outline PostgreSQL - Templates Tools
* Templates are documents with template=true
* @author Descomplicar® | @link descomplicar.pt | @copyright 2026
*/
import { Pool } from 'pg';
import { BaseTool, ToolResponse, PaginationArgs } from '../types/tools.js';
import { validatePagination, isValidUUID, sanitizeInput } from '../utils/security.js';
interface TemplateListArgs extends PaginationArgs {
collection_id?: string;
}
/**
* templates.list - List templates
*/
const listTemplates: BaseTool<TemplateListArgs> = {
name: 'outline_list_templates',
description: 'List document templates. Templates are reusable document structures.',
inputSchema: {
type: 'object',
properties: {
collection_id: { type: 'string', description: 'Filter by collection ID (UUID)' },
limit: { type: 'number', description: 'Max results (default: 25)' },
offset: { type: 'number', description: 'Skip results (default: 0)' },
},
},
handler: async (args, pgClient): Promise<ToolResponse> => {
const { limit, offset } = validatePagination(args.limit, args.offset);
const conditions: string[] = ['d.template = true', 'd."deletedAt" IS NULL'];
const params: any[] = [];
let idx = 1;
if (args.collection_id) {
if (!isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
conditions.push(`d."collectionId" = $${idx++}`);
params.push(args.collection_id);
}
const result = await pgClient.query(`
SELECT
d.id, d.title, d.icon, d."collectionId", d."createdById",
d."createdAt", d."updatedAt",
c.name as "collectionName",
u.name as "createdByName",
(SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount"
FROM documents d
LEFT JOIN collections c ON d."collectionId" = c.id
LEFT JOIN users u ON d."createdById" = u.id
WHERE ${conditions.join(' AND ')}
ORDER BY d."updatedAt" DESC
LIMIT $${idx++} OFFSET $${idx}
`, [...params, limit, offset]);
return {
content: [{ type: 'text', text: JSON.stringify({ data: result.rows, pagination: { limit, offset, total: result.rows.length } }, null, 2) }],
};
},
};
/**
* templates.info - Get template details
*/
const getTemplate: BaseTool<{ id: string }> = {
name: 'outline_get_template',
description: 'Get detailed information about a template including its content.',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string', description: 'Template ID (UUID)' },
},
required: ['id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.id)) throw new Error('Invalid template ID');
const result = await pgClient.query(`
SELECT
d.*, c.name as "collectionName", u.name as "createdByName",
(SELECT COUNT(*) FROM documents WHERE "templateId" = d.id) as "usageCount"
FROM documents d
LEFT JOIN collections c ON d."collectionId" = c.id
LEFT JOIN users u ON d."createdById" = u.id
WHERE d.id = $1 AND d.template = true AND d."deletedAt" IS NULL
`, [args.id]);
if (result.rows.length === 0) throw new Error('Template not found');
return {
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0] }, null, 2) }],
};
},
};
/**
* templates.create - Create document from template
*/
const createFromTemplate: BaseTool<{ template_id: string; title: string; collection_id?: string; parent_document_id?: string }> = {
name: 'outline_create_from_template',
description: 'Create a new document from a template.',
inputSchema: {
type: 'object',
properties: {
template_id: { type: 'string', description: 'Template ID (UUID)' },
title: { type: 'string', description: 'Title for the new document' },
collection_id: { type: 'string', description: 'Collection ID (UUID, optional - uses template collection)' },
parent_document_id: { type: 'string', description: 'Parent document ID (UUID, optional)' },
},
required: ['template_id', 'title'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id');
if (args.collection_id && !isValidUUID(args.collection_id)) throw new Error('Invalid collection_id');
if (args.parent_document_id && !isValidUUID(args.parent_document_id)) throw new Error('Invalid parent_document_id');
// Get template
const template = await pgClient.query(
`SELECT * FROM documents WHERE id = $1 AND template = true AND "deletedAt" IS NULL`,
[args.template_id]
);
if (template.rows.length === 0) throw new Error('Template not found');
const t = template.rows[0];
// Get user
const user = await pgClient.query(`SELECT id FROM users WHERE role = 'admin' AND "deletedAt" IS NULL LIMIT 1`);
const userId = user.rows.length > 0 ? user.rows[0].id : t.createdById;
// Create document from template
const result = await pgClient.query(`
INSERT INTO documents (
id, title, text, icon, "collectionId", "teamId", "parentDocumentId",
"templateId", "createdById", "lastModifiedById", template,
"createdAt", "updatedAt"
)
VALUES (
gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $8, false, NOW(), NOW()
)
RETURNING *
`, [
sanitizeInput(args.title),
t.text,
t.icon,
args.collection_id || t.collectionId,
t.teamId,
args.parent_document_id || null,
args.template_id,
userId,
]);
return {
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Document created from template' }, null, 2) }],
};
},
};
/**
* templates.convert - Convert document to template
*/
const convertToTemplate: BaseTool<{ document_id: string }> = {
name: 'outline_convert_to_template',
description: 'Convert an existing document to a template.',
inputSchema: {
type: 'object',
properties: {
document_id: { type: 'string', description: 'Document ID to convert (UUID)' },
},
required: ['document_id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.document_id)) throw new Error('Invalid document_id');
const result = await pgClient.query(`
UPDATE documents
SET template = true, "updatedAt" = NOW()
WHERE id = $1 AND "deletedAt" IS NULL AND template = false
RETURNING id, title, template
`, [args.document_id]);
if (result.rows.length === 0) throw new Error('Document not found or already a template');
return {
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to template' }, null, 2) }],
};
},
};
/**
* templates.unconvert - Convert template back to document
*/
const convertFromTemplate: BaseTool<{ template_id: string }> = {
name: 'outline_convert_from_template',
description: 'Convert a template back to a regular document.',
inputSchema: {
type: 'object',
properties: {
template_id: { type: 'string', description: 'Template ID to convert (UUID)' },
},
required: ['template_id'],
},
handler: async (args, pgClient): Promise<ToolResponse> => {
if (!isValidUUID(args.template_id)) throw new Error('Invalid template_id');
const result = await pgClient.query(`
UPDATE documents
SET template = false, "updatedAt" = NOW()
WHERE id = $1 AND "deletedAt" IS NULL AND template = true
RETURNING id, title, template
`, [args.template_id]);
if (result.rows.length === 0) throw new Error('Template not found or already a document');
return {
content: [{ type: 'text', text: JSON.stringify({ data: result.rows[0], message: 'Converted to document' }, null, 2) }],
};
},
};
export const templatesTools: BaseTool<any>[] = [
listTemplates, getTemplate, createFromTemplate, convertToTemplate, convertFromTemplate
];