365 lines
12 KiB
JavaScript

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)
}
})
}