feat: add Keycloak-protected MCP authoring endpoint

This commit is contained in:
Admin 2026-09-24 18:36:59 +00:00
parent a11b6e51c8
commit 303c325d39
6 changed files with 505 additions and 8 deletions

View File

@ -90,3 +90,35 @@ Production URL:
```text
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.

View File

@ -21,7 +21,9 @@
"react": "^19.1.1",
"react-dom": "^19.1.1",
"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": {
"@types/react": "^19.1.16",

View File

@ -12,6 +12,10 @@ function config() {
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || '',
baseUrl: process.env.APP_BASE_URL?.replace(/\/$/, '') || '',
allowedApiClients: String(process.env.TEMPLATE_API_ALLOWED_CLIENTS || '')
.split(',')
.map(value => value.trim())
.filter(Boolean),
allowedMcpClients: String(process.env.TEMPLATE_MCP_ALLOWED_CLIENTS || '')
.split(',')
.map(value => value.trim())
.filter(Boolean)
@ -92,22 +96,32 @@ function ensureConfigured() {
if (!issuer || !clientId || !clientSecret) throw new Error('Keycloak OIDC is not fully configured')
}
export async function verifyMachineToken(token) {
const { issuer, allowedApiClients } = config()
async function verifyAllowedMachineToken(token, allowedClients, purpose) {
const { issuer } = config()
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`))
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 allowed = allowedApiClients.some(value => value === client || audiences.includes(value))
if (!allowed) throw new Error('Token client is not allowed to consume templates')
if (!client || !allowedClients.includes(client)) {
throw new Error(`Token client is not allowed for ${purpose}`)
}
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) {
app.get('/auth/login', (req, res, next) => {
try {

View File

@ -2,10 +2,11 @@ import crypto from 'node:crypto'
import express from 'express'
import { chromium } from 'playwright-core'
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 { getTemplateAsset, importTemplatePack, templatePackSpec } from './templatePack.js'
import { getThemeAsset, importThemeJson, importThemePack, themePackSpec } from './themePack.js'
import { createTemplateMcpNodeHandler } from './mcp.js'
const app = express()
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.
*

364
server/mcp.js Normal file
View 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
View 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)
})