chore: checkpoint ai search comments and i18n foundation

This commit is contained in:
2026-03-28 17:17:31 +08:00
parent d18a709987
commit ec96d91548
71 changed files with 9494 additions and 423 deletions

1142
mcp-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
mcp-server/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "termi-mcp-server",
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"start": "node server.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"zod": "^4.3.6"
}
}

518
mcp-server/server.js Normal file
View File

@@ -0,0 +1,518 @@
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}`);
});