import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'; import * as z from 'zod/v4'; const DEFAULT_PORT = 5151; const DEFAULT_HOST = '127.0.0.1'; const DEFAULT_BACKEND_API_BASE = 'http://127.0.0.1:5150/api'; const DEFAULT_API_KEY = 'termi-mcp-local-dev-key'; const HOST = process.env.TERMI_MCP_HOST || DEFAULT_HOST; const PORT = Number.parseInt(process.env.TERMI_MCP_PORT || `${DEFAULT_PORT}`, 10); const API_BASE = (process.env.TERMI_BACKEND_API_BASE || DEFAULT_BACKEND_API_BASE).replace(/\/+$/, ''); const API_KEY = process.env.TERMI_MCP_API_KEY || DEFAULT_API_KEY; function unauthorizedResponse(res) { res.status(401).json({ error: 'Unauthorized', message: 'Missing or invalid MCP API key' }); } function verifyApiKey(req, res, next) { const authorization = req.headers.authorization; const bearerToken = typeof authorization === 'string' && authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length).trim() : null; const xApiKey = typeof req.headers['x-api-key'] === 'string' ? req.headers['x-api-key'].trim() : null; const token = bearerToken || xApiKey; if (!token || token !== API_KEY) { unauthorizedResponse(res); return; } next(); } async function readBackendResponse(response) { const rawText = await response.text(); if (!rawText) { if (response.ok) { return null; } throw new Error(`Backend request failed with status ${response.status}`); } try { const parsed = JSON.parse(rawText); if (!response.ok) { const message = parsed.description || parsed.error || parsed.message || rawText; throw new Error(message); } return parsed; } catch (error) { if (!response.ok) { throw new Error(rawText); } if (error instanceof SyntaxError) { return rawText; } throw error; } } async function requestBackend(method, path, body) { const response = await fetch(`${API_BASE}${path}`, { method, headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }); return readBackendResponse(response); } function createToolResult(title, data) { const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2); const structuredContent = typeof data === 'string' ? { text: data } : Array.isArray(data) ? { items: data } : data; return { content: [ { type: 'text', text: `${title}\n${text}` } ], structuredContent }; } function buildPostsQuery(args) { const query = new URLSearchParams(); const pairs = [ ['slug', args.slug], ['category', args.category], ['tag', args.tag], ['search', args.search], ['type', args.postType] ]; for (const [key, value] of pairs) { if (typeof value === 'string' && value.trim()) { query.set(key, value.trim()); } } if (typeof args.pinned === 'boolean') { query.set('pinned', `${args.pinned}`); } const serialized = query.toString(); return serialized ? `?${serialized}` : ''; } function getServer() { const server = new McpServer( { name: 'termi-blog-mcp', version: '0.1.0' }, { capabilities: { logging: {} } } ); server.registerTool( 'posts_list', { title: 'List Posts', description: '列出博客文章,可按 slug、分类、标签、搜索词和类型过滤。', inputSchema: { slug: z.string().optional(), category: z.string().optional(), tag: z.string().optional(), search: z.string().optional(), postType: z.string().optional(), pinned: z.boolean().optional() } }, async (args) => { const data = await requestBackend('GET', `/posts${buildPostsQuery(args)}`); return createToolResult('Posts list', data); } ); server.registerTool( 'post_get', { title: 'Get Post', description: '按文章 slug 获取结构化文章信息。', inputSchema: { slug: z.string().min(1) } }, async ({ slug }) => { const data = await requestBackend('GET', `/posts/slug/${encodeURIComponent(slug)}`); return createToolResult(`Post ${slug}`, data); } ); server.registerTool( 'post_get_markdown', { title: 'Get Post Markdown', description: '读取文章对应的 Markdown 文件内容和路径。', inputSchema: { slug: z.string().min(1) } }, async ({ slug }) => { const data = await requestBackend( 'GET', `/posts/slug/${encodeURIComponent(slug)}/markdown` ); return createToolResult(`Post markdown ${slug}`, data); } ); server.registerTool( 'post_create_markdown', { title: 'Create Markdown Post', description: '创建新的 Markdown 文章文件,并同步到博客数据库。', inputSchema: { title: z.string().min(1), slug: z.string().optional(), description: z.string().optional(), content: z.string().optional(), category: z.string().optional(), tags: z.array(z.string()).optional(), postType: z.string().optional(), image: z.string().optional(), pinned: z.boolean().optional(), published: z.boolean().optional() } }, async (args) => { const data = await requestBackend('POST', '/posts/markdown', args); return createToolResult('Created markdown post', data); } ); server.registerTool( 'post_update_markdown', { title: 'Update Post Markdown', description: '直接更新文章 Markdown 文件内容,适合 frontmatter 或正文整体重写。', inputSchema: { slug: z.string().min(1), markdown: z.string().min(1) } }, async ({ slug, markdown }) => { const data = await requestBackend( 'PUT', `/posts/slug/${encodeURIComponent(slug)}/markdown`, { markdown } ); return createToolResult(`Updated markdown ${slug}`, data); } ); server.registerTool( 'post_delete_markdown', { title: 'Delete Post Markdown', description: '删除文章 Markdown 文件,并同步删除数据库中的文章记录。', inputSchema: { slug: z.string().min(1) } }, async ({ slug }) => { const data = await requestBackend( 'DELETE', `/posts/slug/${encodeURIComponent(slug)}/markdown` ); return createToolResult(`Deleted markdown ${slug}`, data); } ); server.registerTool( 'categories_list', { title: 'List Categories', description: '列出所有分类和文章数量。', inputSchema: {} }, async () => { const data = await requestBackend('GET', '/categories'); return createToolResult('Categories list', data); } ); server.registerTool( 'category_create', { title: 'Create Category', description: '创建新的分类。', inputSchema: { name: z.string().min(1), slug: z.string().optional() } }, async (args) => { const data = await requestBackend('POST', '/categories', args); return createToolResult('Created category', data); } ); server.registerTool( 'category_update', { title: 'Update Category', description: '更新分类名称或 slug,并同步更新引用它的文章 frontmatter。', inputSchema: { id: z.number().int().positive(), name: z.string().min(1), slug: z.string().optional() } }, async ({ id, ...payload }) => { const data = await requestBackend('PATCH', `/categories/${id}`, payload); return createToolResult(`Updated category ${id}`, data); } ); server.registerTool( 'category_delete', { title: 'Delete Category', description: '删除分类,并移除文章 frontmatter 中对该分类的引用。', inputSchema: { id: z.number().int().positive() } }, async ({ id }) => { await requestBackend('DELETE', `/categories/${id}`); return createToolResult(`Deleted category ${id}`, { id, deleted: true }); } ); server.registerTool( 'tags_list', { title: 'List Tags', description: '列出所有标签。', inputSchema: {} }, async () => { const data = await requestBackend('GET', '/tags'); return createToolResult('Tags list', data); } ); server.registerTool( 'tag_create', { title: 'Create Tag', description: '创建新的标签。', inputSchema: { name: z.string().min(1), slug: z.string().min(1) } }, async (args) => { const data = await requestBackend('POST', '/tags', args); return createToolResult('Created tag', data); } ); server.registerTool( 'tag_update', { title: 'Update Tag', description: '更新标签名称或 slug,并同步更新文章 frontmatter 里的标签。', inputSchema: { id: z.number().int().positive(), name: z.string().min(1), slug: z.string().min(1) } }, async ({ id, ...payload }) => { const data = await requestBackend('PATCH', `/tags/${id}`, payload); return createToolResult(`Updated tag ${id}`, data); } ); server.registerTool( 'tag_delete', { title: 'Delete Tag', description: '删除标签,并从文章 frontmatter 中移除它。', inputSchema: { id: z.number().int().positive() } }, async ({ id }) => { await requestBackend('DELETE', `/tags/${id}`); return createToolResult(`Deleted tag ${id}`, { id, deleted: true }); } ); server.registerTool( 'site_settings_get', { title: 'Get Site Settings', description: '获取前台公开的站点设置。', inputSchema: {} }, async () => { const data = await requestBackend('GET', '/site_settings'); return createToolResult('Site settings', data); } ); server.registerTool( 'site_settings_update', { title: 'Update Site Settings', description: '更新站点设置,包括网站资料和 AI 开关等字段。', inputSchema: { siteName: z.string().optional(), siteShortName: z.string().optional(), siteUrl: z.string().optional(), siteTitle: z.string().optional(), siteDescription: z.string().optional(), heroTitle: z.string().optional(), heroSubtitle: z.string().optional(), ownerName: z.string().optional(), ownerTitle: z.string().optional(), ownerBio: z.string().optional(), ownerAvatarUrl: z.string().optional(), socialGithub: z.string().optional(), socialTwitter: z.string().optional(), socialEmail: z.string().optional(), location: z.string().optional(), techStack: z.array(z.string()).optional(), aiEnabled: z.boolean().optional(), aiProvider: z.string().optional(), aiApiBase: z.string().optional(), aiApiKey: z.string().optional(), aiChatModel: z.string().optional(), aiSystemPrompt: z.string().optional(), aiTopK: z.number().int().min(1).max(12).optional(), aiChunkSize: z.number().int().min(400).max(4000).optional() } }, async (args) => { const data = await requestBackend('PATCH', '/site_settings', args); return createToolResult('Updated site settings', data); } ); server.registerTool( 'ai_reindex', { title: 'Rebuild AI Index', description: '重建博客 AI 检索索引。', inputSchema: {} }, async () => { const data = await requestBackend('POST', '/ai/reindex'); return createToolResult('AI index rebuilt', data); } ); return server; } const app = createMcpExpressApp({ host: HOST }); app.use('/mcp', verifyApiKey); app.get('/health', (_req, res) => { res.json({ ok: true, name: 'termi-blog-mcp', host: HOST, port: PORT, apiBase: API_BASE }); }); app.post('/mcp', async (req, res) => { const server = getServer(); try { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res, req.body); res.on('close', () => { transport.close().catch(() => {}); server.close().catch(() => {}); }); } catch (error) { console.error('MCP request failed:', error); if (!res.headersSent) { res.status(500).json({ jsonrpc: '2.0', error: { code: -32603, message: error instanceof Error ? error.message : 'Internal server error' }, id: null }); } } }); app.get('/mcp', (_req, res) => { res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null }); }); app.delete('/mcp', (_req, res) => { res.status(405).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Method not allowed.' }, id: null }); }); app.listen(PORT, HOST, (error) => { if (error) { console.error('Failed to start Termi MCP server:', error); process.exit(1); } console.log(`Termi MCP server listening on http://${HOST}:${PORT}/mcp`); console.log(`Backend API base: ${API_BASE}`); console.log(`Using API key: ${API_KEY}`); });