Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Successful in 4m43s
docker-images / build-and-push (admin) (push) Successful in 42s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has started running
519 lines
14 KiB
JavaScript
519 lines
14 KiB
JavaScript
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 rebuild job queued', 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}`);
|
||
});
|