365 lines
12 KiB
JavaScript
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)
|
|
}
|
|
})
|
|
}
|