#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListResourcesRequestSchema,
ListResourceTemplatesRequestSchema,
ListToolsRequestSchema,
McpError,
ReadResourceRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import * as fs from 'fs';
import * as path from 'path';
import * as marked from 'marked';
import * as puppeteer from 'puppeteer';
// 主题样式定义
interface Theme {
name: string;
css: string;
description: string;
}
// 卡片配置
interface CardConfig {
theme: string;
type: string;
content: string;
filePath?: string;
}
// 卡片结果
interface CardResult {
html: string;
css: string;
metadata: {
theme: string;
type: string;
wordCount: number;
charCount: number;
};
}
// 图片生成配置
interface ImageConfig {
content: string;
theme: string;
type: string;
filePath?: string;
outputDir?: string;
format?: 'png' | 'jpeg';
quality?: number;
}
// 图片生成结果
interface ImageResult {
imagePath: string;
imageUrl?: string;
metadata: {
theme: string;
type: string;
format: string;
size: number;
wordCount: number;
charCount: number;
};
}
export class LittleRedBookCardMCP {
private server: Server;
private themes: Map<string, Theme> = new Map();
private browser: puppeteer.Browser | null = null;
constructor() {
this.server = new Server(
{
name: 'little-red-book-card-mcp',
version: '1.0.0',
},
{
capabilities: {
resources: {},
tools: {},
},
}
);
this.initializeThemes();
this.setupResourceHandlers();
this.setupToolHandlers();
// 错误处理
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.cleanup();
process.exit(0);
});
}
private async cleanup() {
if (this.browser) {
await this.browser.close();
}
await this.server.close();
}
private initializeThemes(): void {
const themeData: Array<[string, Theme]> = [
['苹果备忘录', {
name: '苹果备忘录',
description: '简洁清新的苹果风格备忘录',
css: `
.card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border: 1px solid #e1e8ed;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #333;
}
`
}],
['波普艺术', {
name: '波普艺术',
description: '鲜艳大胆的波普艺术风格',
css: `
.card {
background: linear-gradient(45deg, #ff0000, #ff8800, #ffff00, #00ff00, #0000ff, #8800ff, #ff0088);
background-size: 400% 400%;
animation: gradient 3s ease infinite;
border: 3px solid #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
font-family: 'Impact', 'Arial Black', sans-serif;
color: #fff;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
}
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
`
}],
['艺术装饰', {
name: '艺术装饰',
description: '奢华的装饰艺术风格',
css: `
.card {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
border: 2px solid #f39c12;
border-radius: 0;
padding: 25px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
font-family: 'Georgia', serif;
color: #ecf0f1;
text-align: center;
}
`
}],
['玻璃拟态', {
name: '玻璃拟态',
description: '现代玻璃拟态效果',
css: `
.card {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 16px;
padding: 20px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
font-family: 'Inter', sans-serif;
color: #333;
}
`
}],
['温暖柔和', {
name: '温暖柔和',
description: '温暖舒适的柔和色调',
css: `
.card {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
border: 1px solid #ff9a8b;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 8px rgba(255, 154, 139, 0.3);
font-family: 'Nunito', sans-serif;
color: #333;
}
`
}],
['简约高级灰', {
name: '简约高级灰',
description: '极简的高级灰色调',
css: `
.card {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-family: 'Helvetica Neue', Arial, sans-serif;
color: #495057;
}
`
}],
['梦幻渐变', {
name: '梦幻渐变',
description: '梦幻般的渐变色彩',
css: `
.card {
background: linear-gradient(120deg, #a8edea 0%, #fed6e3 100%);
border: 1px solid #d1d1d1;
border-radius: 15px;
padding: 20px;
box-shadow: 0 6px 12px rgba(168, 237, 234, 0.3);
font-family: 'Poppins', sans-serif;
color: #333;
}
`
}],
['清新自然', {
name: '清新自然',
description: '清新的自然绿色调',
css: `
.card {
background: linear-gradient(135deg, #e8f5e8 0%, #c8e6c9 100%);
border: 1px solid #81c784;
border-radius: 10px;
padding: 20px;
box-shadow: 0 4px 8px rgba(129, 199, 132, 0.3);
font-family: 'Open Sans', sans-serif;
color: #2e7d32;
}
`
}],
['紫色小红书', {
name: '紫色小红书',
description: '小红书风格的紫色主题',
css: `
.card {
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
border: 1px solid #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 6px 12px rgba(106, 17, 203, 0.4);
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
color: #fff;
}
`
}],
['笔记本', {
name: '笔记本',
description: '仿笔记本纸张效果',
css: `
.card {
background: #fff url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="20"><line x1="0" y1="20" x2="100" y2="20" stroke="%23ddd" stroke-width="1"/></svg>') repeat;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-family: 'Courier New', monospace;
color: #333;
}
`
}],
['暗黑科技', {
name: '暗黑科技',
description: '暗黑科技风格',
css: `
.card {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
border: 1px solid #00b4d8;
border-radius: 12px;
padding: 20px;
box-shadow: 0 8px 16px rgba(0, 180, 216, 0.3);
font-family: 'Orbitron', sans-serif;
color: #00b4d8;
}
`
}],
['复古打字机', {
name: '复古打字机',
description: '复古打字机风格',
css: `
.card {
background: #f5f5dc;
border: 2px solid #8b4513;
border-radius: 0;
padding: 20px;
box-shadow: 0 4px 8px rgba(139, 69, 19, 0.3);
font-family: 'Courier New', monospace;
color: #5d4037;
text-align: left;
}
`
}],
['水彩艺术', {
name: '水彩艺术',
description: '水彩画艺术效果',
css: `
.card {
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><filter id="noise"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="3" stitchTiles="stitch"/></filter><rect width="100%" height="100%" filter="url(%23noise)" opacity="0.1"/></svg>');
border: 1px solid #ff9800;
border-radius: 15px;
padding: 20px;
box-shadow: 0 6px 12px rgba(255, 152, 0, 0.3);
font-family: 'Dancing Script', cursive;
color: #e91e63;
}
`
}],
['中国传统', {
name: '中国传统',
description: '中国传统文化风格',
css: `
.card {
background: linear-gradient(135deg, #fff5ee 0%, #ffe4e1 100%);
border: 2px solid #b22222;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 8px rgba(178, 34, 34, 0.3);
font-family: 'SimSun', 'KaiTi', serif;
color: #8b0000;
text-align: center;
}
`
}],
['儿童童话', {
name: '儿童童话',
description: '儿童童话风格',
css: `
.card {
background: linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%);
border: 3px dashed #ff9800;
border-radius: 20px;
padding: 20px;
box-shadow: 0 8px 16px rgba(255, 152, 0, 0.3);
font-family: 'Comic Sans MS', cursive;
color: #e91e63;
}
`
}],
['商务简报', {
name: '商务简报',
description: '专业商务简报风格',
css: `
.card {
background: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-family: 'Arial', sans-serif;
color: #333;
}
`
}],
['日本杂志', {
name: '日本杂志',
description: '日本杂志排版风格',
css: `
.card {
background: #ffffff;
border-left: 4px solid #ff6b6b;
border-radius: 8px;
padding: 20px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
font-family: 'Hiragino Sans', 'Noto Sans JP', sans-serif;
color: #333;
}
`
}],
['极简黑白', {
name: '极简黑白',
description: '极简黑白配色',
css: `
.card {
background: #ffffff;
border: 1px solid #000000;
border-radius: 0;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
font-family: 'Helvetica Neue', sans-serif;
color: #000000;
}
`
}],
['赛博朋克', {
name: '赛博朋克',
description: '赛博朋克霓虹风格',
css: `
.card {
background: linear-gradient(135deg, #0f0f25 0%, #1a1a40 100%);
border: 2px solid #00ff99;
border-radius: 12px;
padding: 20px;
box-shadow: 0 8px 16px rgba(0, 255, 153, 0.4);
font-family: 'Orbitron', 'Roboto', sans-serif;
color: #00ff99;
text-shadow: 0 0 5px #00ff99;
}
`
}]
];
this.themes = new Map(themeData);
}
private setupResourceHandlers(): void {
// 列出所有可用主题
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
resources: Array.from(this.themes.entries()).map(([key, theme]) => ({
uri: `theme://${key}`,
name: theme.name,
mimeType: 'text/css',
description: theme.description,
})),
}));
// 动态主题模板
this.server.setRequestHandler(
ListResourceTemplatesRequestSchema,
async () => ({
resourceTemplates: [
{
uriTemplate: 'theme://{themeName}',
name: '获取特定主题的CSS样式',
mimeType: 'text/css',
description: '获取指定主题的CSS样式代码',
},
],
})
);
// 读取主题CSS
this.server.setRequestHandler(
ReadResourceRequestSchema,
async (request) => {
const match = request.params.uri.match(/^theme:\/\/([^/]+)$/);
if (!match) {
throw new McpError(
ErrorCode.InvalidRequest,
`Invalid URI format: ${request.params.uri}`
);
}
const themeName = decodeURIComponent(match[1]!);
const theme = this.themes.get(themeName);
if (!theme) {
throw new McpError(
ErrorCode.InvalidRequest,
`Theme not found: ${themeName}`
);
}
return {
contents: [
{
uri: request.params.uri,
mimeType: 'text/css',
text: theme.css,
},
],
};
}
);
}
private async initializeBrowser(): Promise<puppeteer.Browser> {
if (!this.browser) {
this.browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
}
return this.browser;
}
private setupToolHandlers(): void {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'convert_markdown_to_card',
description: '将Markdown内容转换为知识卡片',
inputSchema: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'Markdown内容',
},
theme: {
type: 'string',
description: '主题名称(支持18种主题)',
enum: Array.from(this.themes.keys()),
},
type: {
type: 'string',
description: '卡片类型/尺寸',
enum: ['mobile', 'standard', 'large', 'small', 'wide'],
default: 'mobile'
},
filePath: {
type: 'string',
description: '可选的Markdown文件路径',
},
},
required: ['content', 'theme'],
},
},
{
name: 'generate_card_image',
description: '将Markdown内容转换为知识卡片图片',
inputSchema: {
type: 'object',
properties: {
content: {
type: 'string',
description: 'Markdown内容',
},
theme: {
type: 'string',
description: '主题名称(支持18种主题)',
enum: Array.from(this.themes.keys()),
},
type: {
type: 'string',
description: '卡片类型/尺寸',
enum: ['mobile', 'standard', 'large', 'small', 'wide'],
default: 'mobile'
},
filePath: {
type: 'string',
description: '可选的Markdown文件路径',
},
outputDir: {
type: 'string',
description: '输出目录,默认为当前目录',
default: './'
},
format: {
type: 'string',
description: '图片格式',
enum: ['png', 'jpeg'],
default: 'png'
},
quality: {
type: 'number',
description: '图片质量(仅jpeg格式)',
minimum: 1,
maximum: 100,
default: 80
}
},
required: ['content', 'theme'],
},
},
{
name: 'list_available_themes',
description: '列出所有可用的主题样式',
inputSchema: {
type: 'object',
properties: {},
},
},
],
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name === 'convert_markdown_to_card') {
return await this.handleConvertMarkdown(request);
} else if (request.params.name === 'generate_card_image') {
return await this.handleGenerateCardImage(request);
} else if (request.params.name === 'list_available_themes') {
return await this.handleListThemes(request);
}
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
});
}
private async handleConvertMarkdown(request: any): Promise<any> {
const { content, theme, type = 'mobile', filePath } = request.params.arguments;
if (!content && !filePath) {
throw new McpError(
ErrorCode.InvalidParams,
'Either content or filePath must be provided'
);
}
let markdownContent = content;
if (filePath) {
try {
markdownContent = await fs.promises.readFile(filePath!, 'utf-8');
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to read file: ${(error as Error).message}`
);
}
}
try {
const result = await this.convertMarkdownToCard({
content: markdownContent,
theme,
type,
});
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Conversion failed: ${(error as Error).message}`,
},
],
isError: true,
};
}
}
private async handleGenerateCardImage(request: any): Promise<any> {
const { content, theme, type = 'mobile', filePath, outputDir = './', format = 'png', quality = 80 } = request.params.arguments;
if (!content && !filePath) {
throw new McpError(
ErrorCode.InvalidParams,
'Either content or filePath must be provided'
);
}
let markdownContent = content;
if (filePath) {
try {
markdownContent = await fs.promises.readFile(filePath!, 'utf-8');
} catch (error) {
throw new McpError(
ErrorCode.InternalError,
`Failed to read file: ${(error as Error).message}`
);
}
}
try {
const result = await this.generateCardImage({
content: markdownContent,
theme,
type,
outputDir,
format,
quality
});
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
return {
content: [
{
type: 'text',
text: `Image generation failed: ${(error as Error).message}`,
},
],
isError: true,
};
}
}
private async handleListThemes(request: any): Promise<any> {
const themes = Array.from(this.themes.entries()).map(([key, theme]) => ({
name: theme.name,
key,
description: theme.description,
}));
return {
content: [
{
type: 'text',
text: JSON.stringify({
themes,
count: themes.length,
}, null, 2),
},
],
};
}
private async convertMarkdownToCard(config: CardConfig): Promise<CardResult | CardResult[]> {
// 检查是否需要拆分长文
const wordCount = config.content.split(/\s+/).filter(word => word.length > 0).length;
const charCount = config.content.length;
// 如果内容过长,自动拆分
if (charCount > 5000) {
return this.splitLongContent(config, wordCount, charCount);
}
// 解析Markdown
const htmlContent = await marked.parse(config.content);
// 获取主题CSS
const theme = this.themes.get(config.theme);
if (!theme) {
throw new Error(`Theme not found: ${config.theme}`);
}
// 根据类型生成尺寸CSS(默认mobile)
const typeCss = this.getTypeCss(config.type || 'mobile');
// 创建卡片HTML
const cardHtml = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
${theme.css}
${typeCss}
.card-content {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-content">${htmlContent}</div>
</div>
</body>
</html>
`;
return {
html: cardHtml,
css: theme.css,
metadata: {
theme: config.theme,
type: config.type,
wordCount,
charCount,
},
};
}
private async generateCardImage(config: ImageConfig): Promise<ImageResult> {
// 首先生成HTML卡片
const cardResult = await this.convertMarkdownToCard({
content: config.content,
theme: config.theme,
type: config.type,
filePath: config.filePath
});
if (Array.isArray(cardResult)) {
throw new Error('长内容不支持直接生成图片,请使用单个卡片');
}
// 使用默认输出目录
const outputDir = config.outputDir || './';
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 生成唯一的文件名
const timestamp = Date.now();
const filename = `card-${timestamp}.${config.format || 'png'}`;
const imagePath = path.join(outputDir, filename);
// 初始化浏览器
const browser = await this.initializeBrowser();
const page = await browser.newPage();
try {
// 设置页面内容
await page.setContent(cardResult.html, { waitUntil: 'networkidle0' });
// 根据卡片内容自动调整视口大小
const cardElement = await page.$('.card');
if (!cardElement) {
throw new Error('未找到卡片元素');
}
const boundingBox = await cardElement.boundingBox();
if (!boundingBox) {
throw new Error('无法获取卡片尺寸');
}
// 设置视口为卡片的实际大小
await page.setViewport({
width: Math.ceil(boundingBox.width + 40), // 添加一些边距
height: Math.ceil(boundingBox.height + 40),
deviceScaleFactor: 2 // 高DPI
});
// 截图
const imageBuffer = await page.screenshot({
type: config.format || 'png',
quality: config.format === 'jpeg' ? (config.quality || 80) : undefined,
omitBackground: true
});
// 保存图片
fs.writeFileSync(imagePath, imageBuffer);
return {
imagePath: path.resolve(imagePath),
metadata: {
theme: config.theme,
type: config.type,
format: config.format || 'png',
size: imageBuffer.length,
wordCount: cardResult.metadata.wordCount,
charCount: cardResult.metadata.charCount
}
};
} finally {
await page.close();
}
}
private async splitLongContent(config: CardConfig, totalWordCount: number, totalCharCount: number): Promise<CardResult[]> {
const chunks = this.splitContentIntoChunks(config.content, 4000);
const results: CardResult[] = [];
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
// 确保chunk不为空
if (!chunk || chunk.trim().length === 0) {
continue;
}
const chunkWordCount = chunk.split(/\s+/).filter(word => word.length > 0).length;
const chunkCharCount = chunk.length;
// 解析Markdown
const htmlContent = await marked.parse(chunk);
// 获取主题CSS
const theme = this.themes.get(config.theme);
if (!theme) {
throw new Error(`Theme not found: ${config.theme}`);
}
// 创建卡片HTML
const cardHtml = `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
${theme.css}
.card-content {
margin: 0;
padding: 0;
}
.card-header {
font-size: 0.9em;
color: #666;
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">第 ${i + 1} 部分 (共 ${chunks.length} 部分)</div>
<div class="card-content">${htmlContent}</div>
</div>
</body>
</html>
`;
results.push({
html: cardHtml,
css: theme.css,
metadata: {
theme: config.theme,
type: config.type,
wordCount: chunkWordCount,
charCount: chunkCharCount,
},
});
}
return results;
}
private splitContentIntoChunks(content: string, chunkSize: number): string[] {
const chunks: string[] = [];
let currentPos = 0;
while (currentPos < content.length) {
let chunk = content.substring(currentPos, currentPos + chunkSize);
// 如果不是最后一块,尝试在段落或句子结束处分割
if (currentPos + chunkSize < content.length) {
const lastParagraph = chunk.lastIndexOf('\n\n');
const lastSentence = chunk.lastIndexOf('. ');
const lastLine = chunk.lastIndexOf('\n');
let splitPos = Math.max(lastParagraph, lastSentence, lastLine);
if (splitPos > chunkSize * 0.7 && splitPos >= 0) {
chunk = content.substring(currentPos, currentPos + splitPos + (splitPos === lastParagraph ? 2 : 1));
}
}
chunks.push(chunk);
currentPos += chunk.length;
}
return chunks;
}
// 按类型返回尺寸CSS,默认为手机端大小
private getTypeCss(type: string): string {
const normalized = (type || 'mobile').toLowerCase();
switch (normalized) {
case 'mobile':
return `
.card { max-width: 390px; width: 390px; margin: 0 auto; }
@media (max-width: 420px) { .card { width: 100%; max-width: 390px; } }
`;
case 'small':
return `
.card { max-width: 480px; width: 480px; margin: 0 auto; }
`;
case 'standard':
return `
.card { max-width: 800px; width: 800px; margin: 0 auto; }
`;
case 'wide':
return `
.card { max-width: 1024px; width: 1024px; margin: 0 auto; }
`;
case 'large':
return `
.card { max-width: 1200px; width: 1200px; margin: 0 auto; }
`;
default:
return `
.card { max-width: 390px; width: 390px; margin: 0 auto; }
`;
}
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.log('Little Red Book Card MCP server running on stdio');
}
}
const server = new LittleRedBookCardMCP();
server.run().catch(console.error);