feat: 支持 MCP 图片内联渲染

This commit is contained in:
shiyue
2026-08-06 13:37:42 +08:00
parent 3f00cfe7a4
commit 3ddf21af85
6 changed files with 237 additions and 3 deletions

View File

@@ -3,6 +3,8 @@
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const SERVER_INFO = {
name: 'ccweb',
@@ -10,6 +12,20 @@ const SERVER_INFO = {
};
const TOOLS = [
{
name: 'ccweb_display_image',
description: '在当前会话的助手气泡中显示图片。支持 http/https 图片地址、data:image/*;base64 图片或本地图片绝对路径(最大 10 MB。',
inputSchema: {
type: 'object',
properties: {
source: { type: 'string', description: '图片 URL、data:image/*;base64,... 或本地绝对路径。' },
alt: { type: 'string', maxLength: 240, description: '图片替代文本。' },
title: { type: 'string', maxLength: 240, description: '图片标题。' },
},
required: ['source'],
additionalProperties: false,
},
},
{
name: 'ccweb_list_conversations',
description: '列出当前 ccweb 中可投递消息的对话。只返回 ID、标题、Agent、运行状态和更新时间不返回对话正文。',
@@ -257,6 +273,31 @@ const TOOLS = [
},
];
function imageMimeFromPath(filePath) {
const ext = path.extname(filePath).toLowerCase();
return ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' })[ext] || 'application/octet-stream';
}
function prepareImagePayload(args = {}) {
const source = String(args.source || '').trim();
if (!source) return { ok: false, code: 'missing_source', message: 'source 不能为空。' };
if (/^https?:\/\//i.test(source)) return { ok: true, image: { type: 'image', url: source, mimeType: '', alt: String(args.alt || ''), title: String(args.title || '') } };
if (/^data:image\//i.test(source)) {
const match = source.match(/^data:(image\/[\w.+-]+);base64,([\s\S]+)$/i);
if (!match) return { ok: false, code: 'invalid_data_image', message: '仅支持 base64 编码的 data:image 图片。' };
if (Buffer.byteLength(match[2], 'base64') > 10 * 1024 * 1024) return { ok: false, code: 'image_too_large', message: '图片不能超过 10 MB。' };
return { ok: true, image: { type: 'image', data: match[2], mimeType: match[1], alt: String(args.alt || ''), title: String(args.title || '') } };
}
if (!path.isAbsolute(source)) return { ok: false, code: 'invalid_local_path', message: '本地图片必须使用绝对路径。' };
try {
const stat = fs.statSync(source);
if (!stat.isFile() || stat.size > 10 * 1024 * 1024) return { ok: false, code: 'image_too_large', message: '本地图片不存在或超过 10 MB。' };
const mimeType = imageMimeFromPath(source);
if (!mimeType.startsWith('image/')) return { ok: false, code: 'unsupported_image_type', message: '不支持该本地图片格式。' };
return { ok: true, image: { type: 'image', data: fs.readFileSync(source).toString('base64'), mimeType, alt: String(args.alt || path.basename(source)), title: String(args.title || path.basename(source)) } };
} catch { return { ok: false, code: 'image_read_failed', message: '无法读取本地图片。' }; }
}
function writeMessage(message) {
process.stdout.write(`${JSON.stringify(message)}\n`);
}
@@ -452,7 +493,7 @@ function runStdioServer() {
});
}
module.exports = { TOOLS, runStdioServer };
module.exports = { TOOLS, prepareImagePayload, runStdioServer };
if (require.main === module) {
runStdioServer();