Files
termi-blog/mcp-server/server.js

519 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
});