feat: add Keycloak-protected MCP authoring endpoint
This commit is contained in:
parent
a11b6e51c8
commit
303c325d39
32
README.md
32
README.md
@ -90,3 +90,35 @@ Production URL:
|
|||||||
```text
|
```text
|
||||||
https://templates.arrowstack.dev
|
https://templates.arrowstack.dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## MCP authoring endpoint
|
||||||
|
|
||||||
|
Template Studio exposes a Keycloak-protected Streamable HTTP MCP endpoint at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://templates.arrowstack.dev/mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
This endpoint is separate from the published-template consumer API and from the browser editor session. It accepts only:
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <Keycloak access token>
|
||||||
|
```
|
||||||
|
|
||||||
|
The token must be issued by the configured Keycloak realm and its `azp` client ID must appear in `TEMPLATE_MCP_ALLOWED_CLIENTS`. API keys and browser cookies are intentionally not accepted for MCP authoring.
|
||||||
|
|
||||||
|
The endpoint exposes these tools:
|
||||||
|
|
||||||
|
- `templates_list`
|
||||||
|
- `template_get`
|
||||||
|
- `template_create`
|
||||||
|
- `template_update_draft`
|
||||||
|
- `template_publish`
|
||||||
|
- `folders_list`
|
||||||
|
- `folder_create`
|
||||||
|
- `template_move`
|
||||||
|
|
||||||
|
`template_update_draft` accepts `expected_updated_at`; callers should pass the value returned by `template_get` to detect concurrent edits instead of silently overwriting them.
|
||||||
|
|
||||||
|
The MCP surface does not expose API-key management, authentication administration, template deletion, or arbitrary database access.
|
||||||
|
|||||||
@ -21,7 +21,9 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"zod": "^4.1.11",
|
"zod": "^4.1.11",
|
||||||
"adm-zip": "^0.5.16"
|
"adm-zip": "^0.5.16",
|
||||||
|
"@modelcontextprotocol/server": "^2.0.0",
|
||||||
|
"@modelcontextprotocol/node": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.1.16",
|
"@types/react": "^19.1.16",
|
||||||
|
|||||||
@ -12,6 +12,10 @@ function config() {
|
|||||||
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
|
||||||
baseUrl: process.env.APP_BASE_URL?.replace(/\/$/, '') || '',
|
baseUrl: process.env.APP_BASE_URL?.replace(/\/$/, '') || '',
|
||||||
allowedApiClients: String(process.env.TEMPLATE_API_ALLOWED_CLIENTS || '')
|
allowedApiClients: String(process.env.TEMPLATE_API_ALLOWED_CLIENTS || '')
|
||||||
|
.split(',')
|
||||||
|
.map(value => value.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
allowedMcpClients: String(process.env.TEMPLATE_MCP_ALLOWED_CLIENTS || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map(value => value.trim())
|
.map(value => value.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@ -92,22 +96,32 @@ function ensureConfigured() {
|
|||||||
if (!issuer || !clientId || !clientSecret) throw new Error('Keycloak OIDC is not fully configured')
|
if (!issuer || !clientId || !clientSecret) throw new Error('Keycloak OIDC is not fully configured')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyMachineToken(token) {
|
async function verifyAllowedMachineToken(token, allowedClients, purpose) {
|
||||||
const { issuer, allowedApiClients } = config()
|
const { issuer } = config()
|
||||||
if (!issuer) throw new Error('KEYCLOAK_ISSUER is required')
|
if (!issuer) throw new Error('KEYCLOAK_ISSUER is required')
|
||||||
if (!allowedApiClients.length) throw new Error('No API clients are allowed')
|
if (!allowedClients.length) throw new Error(`No ${purpose} clients are allowed`)
|
||||||
|
|
||||||
jwks ||= createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`))
|
jwks ||= createRemoteJWKSet(new URL(`${issuer}/protocol/openid-connect/certs`))
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer })
|
const { payload } = await jwtVerify(token, jwks, { issuer })
|
||||||
|
|
||||||
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud].filter(Boolean)
|
|
||||||
const client = String(payload.azp || '')
|
const client = String(payload.azp || '')
|
||||||
const allowed = allowedApiClients.some(value => value === client || audiences.includes(value))
|
if (!client || !allowedClients.includes(client)) {
|
||||||
if (!allowed) throw new Error('Token client is not allowed to consume templates')
|
throw new Error(`Token client is not allowed for ${purpose}`)
|
||||||
|
}
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function verifyMachineToken(token) {
|
||||||
|
const { allowedApiClients } = config()
|
||||||
|
return verifyAllowedMachineToken(token, allowedApiClients, 'template consumption')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyMcpToken(token) {
|
||||||
|
const { allowedMcpClients } = config()
|
||||||
|
return verifyAllowedMachineToken(token, allowedMcpClients, 'template authoring')
|
||||||
|
}
|
||||||
|
|
||||||
export function registerAuthRoutes(app) {
|
export function registerAuthRoutes(app) {
|
||||||
app.get('/auth/login', (req, res, next) => {
|
app.get('/auth/login', (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import crypto from 'node:crypto'
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import { chromium } from 'playwright-core'
|
import { chromium } from 'playwright-core'
|
||||||
import { initDb, getPool } from './db.js'
|
import { initDb, getPool } from './db.js'
|
||||||
import { readSession, registerAuthRoutes, requireAuth, verifyMachineToken } from './auth.js'
|
import { readSession, registerAuthRoutes, requireAuth, verifyMachineToken, verifyMcpToken } from './auth.js'
|
||||||
import { renderEmailMessage, renderReportHtml } from './templateEngine.js'
|
import { renderEmailMessage, renderReportHtml } from './templateEngine.js'
|
||||||
import { getTemplateAsset, importTemplatePack, templatePackSpec } from './templatePack.js'
|
import { getTemplateAsset, importTemplatePack, templatePackSpec } from './templatePack.js'
|
||||||
import { getThemeAsset, importThemeJson, importThemePack, themePackSpec } from './themePack.js'
|
import { getThemeAsset, importThemeJson, importThemePack, themePackSpec } from './themePack.js'
|
||||||
|
import { createTemplateMcpNodeHandler } from './mcp.js'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const port = Number(process.env.PORT || 3000)
|
const port = Number(process.env.PORT || 3000)
|
||||||
@ -213,6 +214,71 @@ async function sendRendered(res, type, document, data, format, metadata = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function mcpBearerToken(req) {
|
||||||
|
const header = String(req.headers.authorization || '')
|
||||||
|
return header.startsWith('Bearer ') ? header.slice(7).trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateMcpOrigin(req, res, next) {
|
||||||
|
const origin = String(req.headers.origin || '').trim()
|
||||||
|
const appBaseUrl = String(process.env.APP_BASE_URL || '').replace(/\/$/, '')
|
||||||
|
if (!origin || !appBaseUrl) return next()
|
||||||
|
try {
|
||||||
|
if (new URL(origin).origin !== new URL(appBaseUrl).origin) {
|
||||||
|
return res.status(403).json({ error: 'mcp_origin_not_allowed' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return res.status(403).json({ error: 'mcp_origin_not_allowed' })
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireMcpAuth(req, res, next) {
|
||||||
|
const token = mcpBearerToken(req)
|
||||||
|
if (!token) {
|
||||||
|
res.setHeader('WWW-Authenticate', 'Bearer')
|
||||||
|
return res.status(401).json({ error: 'mcp_bearer_token_required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await verifyMcpToken(token)
|
||||||
|
const clientId = String(payload.azp || '')
|
||||||
|
req.auth = {
|
||||||
|
token,
|
||||||
|
clientId,
|
||||||
|
scopes: String(payload.scope || '').split(/\s+/).filter(Boolean),
|
||||||
|
expiresAt: Number(payload.exp || 0) || undefined,
|
||||||
|
extra: {
|
||||||
|
subject: payload.sub,
|
||||||
|
preferred_username: payload.preferred_username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
} catch {
|
||||||
|
res.setHeader('WWW-Authenticate', 'Bearer error="invalid_token"')
|
||||||
|
return res.status(401).json({ error: 'invalid_mcp_bearer_token' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateMcpNodeHandler = createTemplateMcpNodeHandler({ tenantId })
|
||||||
|
|
||||||
|
app.get('/.well-known/oauth-protected-resource', (_req, res) => {
|
||||||
|
const issuer = String(process.env.KEYCLOAK_ISSUER || '').replace(/\/$/, '')
|
||||||
|
const baseUrl = String(process.env.APP_BASE_URL || '').replace(/\/$/, '')
|
||||||
|
if (!issuer || !baseUrl) return res.status(404).end()
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=300')
|
||||||
|
res.json({
|
||||||
|
resource: `${baseUrl}/mcp`,
|
||||||
|
authorization_servers: [issuer],
|
||||||
|
bearer_methods_supported: ['header']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.all('/mcp', validateMcpOrigin, requireMcpAuth, (req, res, next) => {
|
||||||
|
Promise.resolve(templateMcpNodeHandler(req, res, req.body)).catch(next)
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Published consumer API.
|
* Published consumer API.
|
||||||
*
|
*
|
||||||
|
|||||||
364
server/mcp.js
Normal file
364
server/mcp.js
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
19
server/mcp.test.js
Normal file
19
server/mcp.test.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { TEMPLATE_MCP_TOOL_NAMES, createTemplateMcpNodeHandler } from './mcp.js'
|
||||||
|
|
||||||
|
test('MCP authoring surface stays intentionally narrow', () => {
|
||||||
|
assert.deepEqual(TEMPLATE_MCP_TOOL_NAMES, [
|
||||||
|
'templates_list',
|
||||||
|
'template_get',
|
||||||
|
'template_create',
|
||||||
|
'template_update_draft',
|
||||||
|
'template_publish',
|
||||||
|
'folders_list',
|
||||||
|
'folder_create',
|
||||||
|
'template_move'
|
||||||
|
])
|
||||||
|
assert.equal(typeof createTemplateMcpNodeHandler, 'function')
|
||||||
|
assert.equal(TEMPLATE_MCP_TOOL_NAMES.includes('api_key_create'), false)
|
||||||
|
assert.equal(TEMPLATE_MCP_TOOL_NAMES.includes('template_delete'), false)
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user