import crypto from 'node:crypto' import { createMcpHandler, McpServer } from '@modelcontextprotocol/server' import { toNodeHandler } from '@modelcontextprotocol/node' import * as z from 'zod/v4' import { getPool } from './db.js' const TemplateType = z.enum(['report', 'email']) const JsonObject = z.record(z.string(), z.unknown()) function textResult(value) { return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] } } function normalizeUpdatedAt(value) { if (!value) return null const date = value instanceof Date ? value : new Date(value) return Number.isNaN(date.getTime()) ? String(value) : date.toISOString() } function assertDocument(document) { if (!document || typeof document !== 'object' || !Array.isArray(document.blocks)) { throw new Error('document must be an object with a blocks array') } } function defaultDocument(type) { return { blocks: [ { id: crypto.randomUUID(), type: 'heading', props: { text: type === 'email' ? 'Hello {{customer.name}}' : 'Report — {{report.date}}' } }, { id: crypto.randomUUID(), type: 'text', props: { text: type === 'email' ? 'Add your message here.' : 'Add report context here.' } }, { id: crypto.randomUUID(), type: 'kpi', props: { label: 'Total', value: '{{summary.total}}' } } ] } } function slug(value) { return String(value || '') .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 48) || 'template' } async function newTemplateKey(tenantId, name) { for (let attempt = 0; attempt < 8; attempt += 1) { const candidate = `${slug(name)}-${crypto.randomBytes(3).toString('hex')}` const { rowCount } = await getPool().query( 'SELECT 1 FROM templates WHERE tenant_id=$1 AND template_key=$2', [tenantId, candidate] ) if (!rowCount) return candidate } return `template-${crypto.randomUUID()}` } async function assertFolder(tenantId, folderId) { if (!folderId) return const { rowCount } = await getPool().query( 'SELECT 1 FROM folders WHERE id=$1 AND tenant_id=$2', [folderId, tenantId] ) if (!rowCount) throw new Error('folder not found') } function makeServer({ tenantId, actor }) { const server = new McpServer( { name: 'template-studio', version: '0.1.0' }, { capabilities: { tools: {} } } ) server.registerTool( 'templates_list', { description: 'List editable Template Studio templates, optionally filtered by type, folder, or search text.', inputSchema: z.object({ type: TemplateType.optional(), folder_id: z.string().nullable().optional(), search: z.string().max(200).optional(), limit: z.number().int().min(1).max(100).default(50) }) }, async ({ type, folder_id, search, limit }) => { const conditions = ['tenant_id=$1'] const values = [tenantId] if (type) { values.push(type) conditions.push(`type=$${values.length}`) } if (folder_id !== undefined) { values.push(folder_id) conditions.push(`folder_id IS NOT DISTINCT FROM $${values.length}`) } if (search?.trim()) { values.push(`%${search.trim()}%`) conditions.push(`(name ILIKE $${values.length} OR template_key ILIKE $${values.length})`) } values.push(limit) const { rows } = await getPool().query( `SELECT id,template_key,name,type,status,published_version,folder_id,updated_at FROM templates WHERE ${conditions.join(' AND ')} ORDER BY updated_at DESC LIMIT $${values.length}`, values ) return textResult({ templates: rows }) } ) server.registerTool( 'template_get', { description: 'Get one editable template including its draft document and sample data.', inputSchema: z.object({ id: z.string().min(1) }) }, async ({ id }) => { const { rows } = await getPool().query( `SELECT id,template_key,name,type,status,published_version,folder_id,draft_document,sample_data,updated_at FROM templates WHERE id=$1 AND tenant_id=$2 LIMIT 1`, [id, tenantId] ) if (!rows[0]) throw new Error('template not found') return textResult(rows[0]) } ) server.registerTool( 'template_create', { description: 'Create a new report or email template. A default block document is created unless document is supplied.', inputSchema: z.object({ name: z.string().trim().min(1).max(160), type: TemplateType, folder_id: z.string().nullable().optional(), document: JsonObject.optional(), sample_data: JsonObject.optional() }) }, async ({ name, type, folder_id, document, sample_data }) => { await assertFolder(tenantId, folder_id) const draft = document || defaultDocument(type) assertDocument(draft) const data = sample_data || { customer: { name: 'Example Customer' }, report: { date: new Date().toISOString().slice(0, 10) }, summary: { total: 125 }, items: [{ name: 'Example row', value: 125 }] } const id = crypto.randomUUID() const templateKey = await newTemplateKey(tenantId, name) const { rows } = await getPool().query( `INSERT INTO templates (id,tenant_id,template_key,name,type,folder_id,draft_document,sample_data,created_by,updated_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$9) RETURNING id,template_key,name,type,status,published_version,folder_id,draft_document,sample_data,updated_at`, [id, tenantId, templateKey, name, type, folder_id || null, draft, data, actor] ) return textResult(rows[0]) } ) server.registerTool( 'template_update_draft', { description: 'Update an editable template draft. Use expected_updated_at from template_get to prevent overwriting a newer edit.', inputSchema: z.object({ id: z.string().min(1), expected_updated_at: z.string().optional(), name: z.string().trim().min(1).max(160).optional(), folder_id: z.string().nullable().optional(), document: JsonObject.optional(), sample_data: JsonObject.optional() }) }, async ({ id, expected_updated_at, name, folder_id, document, sample_data }) => { const currentResult = await getPool().query( 'SELECT * FROM templates WHERE id=$1 AND tenant_id=$2 LIMIT 1', [id, tenantId] ) const current = currentResult.rows[0] if (!current) throw new Error('template not found') if (expected_updated_at && normalizeUpdatedAt(current.updated_at) !== normalizeUpdatedAt(expected_updated_at)) { throw new Error(`edit conflict: template changed at ${normalizeUpdatedAt(current.updated_at)}`) } if (document !== undefined) assertDocument(document) if (folder_id !== undefined) await assertFolder(tenantId, folder_id) const nextDocument = document === undefined ? current.draft_document : document const nextData = sample_data === undefined ? current.sample_data : sample_data const nextName = name === undefined ? current.name : name const nextFolder = folder_id === undefined ? current.folder_id : folder_id const { rows } = await getPool().query( `UPDATE templates SET name=$1,folder_id=$2,draft_document=$3,sample_data=$4,updated_by=$5,updated_at=now() WHERE id=$6 AND tenant_id=$7 RETURNING id,template_key,name,type,status,published_version,folder_id,draft_document,sample_data,updated_at`, [nextName, nextFolder, nextDocument, nextData, actor, id, tenantId] ) return textResult(rows[0]) } ) server.registerTool( 'template_publish', { description: 'Publish the current draft as a new immutable template version.', inputSchema: z.object({ id: z.string().min(1) }) }, async ({ id }) => { const db = await getPool().connect() try { await db.query('BEGIN') const currentResult = await db.query( 'SELECT * FROM templates WHERE id=$1 AND tenant_id=$2 FOR UPDATE', [id, tenantId] ) const template = currentResult.rows[0] if (!template) throw new Error('template not found') const nextVersion = (template.published_version || 0) + 1 await db.query( `INSERT INTO template_versions (id,template_id,version,document,sample_data,created_by) VALUES ($1,$2,$3,$4,$5,$6)`, [crypto.randomUUID(), template.id, nextVersion, template.draft_document, template.sample_data, actor] ) const updated = await db.query( `UPDATE templates SET status='published',published_version=$1,updated_at=now(),updated_by=$2 WHERE id=$3 RETURNING id,template_key,name,type,status,published_version,folder_id,updated_at`, [nextVersion, actor, template.id] ) await db.query('COMMIT') return textResult(updated.rows[0]) } catch (error) { await db.query('ROLLBACK') throw error } finally { db.release() } } ) server.registerTool( 'folders_list', { description: 'List template folders and their parent relationships.', inputSchema: z.object({}) }, async () => { const { rows } = await getPool().query( `SELECT f.id,f.name,f.parent_id,f.created_at,f.updated_at, COUNT(t.id)::int AS template_count FROM folders f LEFT JOIN templates t ON t.tenant_id=f.tenant_id AND t.folder_id=f.id WHERE f.tenant_id=$1 GROUP BY f.id ORDER BY lower(f.name), f.created_at`, [tenantId] ) return textResult({ folders: rows }) } ) server.registerTool( 'folder_create', { description: 'Create a folder for organizing templates.', inputSchema: z.object({ name: z.string().trim().min(1).max(120), parent_id: z.string().nullable().optional() }) }, async ({ name, parent_id }) => { await assertFolder(tenantId, parent_id) try { const { rows } = await getPool().query( `INSERT INTO folders (id,tenant_id,name,parent_id,created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id,name,parent_id,created_at,updated_at`, [crypto.randomUUID(), tenantId, name, parent_id || null, actor] ) return textResult(rows[0]) } catch (error) { if (error?.code === '23505') throw new Error('a folder with that name already exists here') throw error } } ) server.registerTool( 'template_move', { description: 'Move a template to a folder or to the library root without changing its template key or published versions.', inputSchema: z.object({ id: z.string().min(1), folder_id: z.string().nullable() }) }, async ({ id, folder_id }) => { await assertFolder(tenantId, folder_id) const { rows } = await getPool().query( `UPDATE templates SET folder_id=$1,updated_by=$2,updated_at=now() WHERE id=$3 AND tenant_id=$4 RETURNING id,template_key,name,type,status,published_version,folder_id,updated_at`, [folder_id, actor, id, tenantId] ) if (!rows[0]) throw new Error('template not found') return textResult(rows[0]) } ) return server } export const TEMPLATE_MCP_TOOL_NAMES = [ 'templates_list', 'template_get', 'template_create', 'template_update_draft', 'template_publish', 'folders_list', 'folder_create', 'template_move' ] export function createTemplateMcpNodeHandler({ tenantId }) { const handler = createMcpHandler(({ authInfo }) => { const clientId = authInfo?.clientId || 'unknown' return makeServer({ tenantId, actor: `mcp:${clientId}` }) }) return toNodeHandler(handler, { onerror(error) { console.error('Template Studio MCP transport error', error) } }) }