diff --git a/README.md b/README.md index 26dfefa..22be34f 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +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. diff --git a/package.json b/package.json index 5c1260a..d8c21f7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/auth.js b/server/auth.js index b516707..a50172b 100644 --- a/server/auth.js +++ b/server/auth.js @@ -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 { diff --git a/server/index.js b/server/index.js index 0cf3c45..9670de3 100644 --- a/server/index.js +++ b/server/index.js @@ -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. * diff --git a/server/mcp.js b/server/mcp.js new file mode 100644 index 0000000..55064be --- /dev/null +++ b/server/mcp.js @@ -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) + } + }) +} diff --git a/server/mcp.test.js b/server/mcp.test.js new file mode 100644 index 0000000..038bfd1 --- /dev/null +++ b/server/mcp.test.js @@ -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) +})