7.4 综合实战:视觉代理MCP服务器
在本节的实例中,实现了一个轻量级的MCP 服务器,由Landing AI团队开发,能将来自兼容 MCP 协议的客户端(如Claude Desktop、Cursor、Cline等)的每个工具调用,转换为对Landing AI的VisionAgent REST API的认证HTTPS请求,并将包含图像或掩码的响应JSON流回模型,让用户无需编写自定义 REST代码或加载额外SDK,就能在编辑器中发出自然语言的计算机视觉和文档分析命令。
实例7-2:执行计算机视觉和文档分析任务的MCP服务器(源码路径:codes\7\vision-agent-mcp)
7.4.1 项目介绍
本项实现了一个MCP服务器,允许用户通过自然语言命令在编辑器中执行计算机视觉和文档分析任务。本项目的主要功能模块如下所示。
- agentic-document-analysis:解析PDF/图像,提取文本、表格、图表和diagrams,同时考虑布局和其他视觉线索(有Web版本)
- text-to-object-detection:使用OWLv2、CountGD、Florence-2、AgenticObjectDetection检测自由形式的提示(如“所有交通灯”),输出边界框(有Web版本)
- text-to-instance-segmentation :通过Florence-2+Segment-Anything-v2(SAM-2)实现像素级完美掩码
- activity-recognition :识别视频中的多个活动,并提供开始/结束时间戳
- depth-pro :对单张图像进行高分辨率单目深度估计
7.4.2 通用工具
(1)文件src/utils/errors.ts定义了一个错误处理模块,用于在Node.js应用程序中创建和管理自定义错误类型。它扩展了JavaScript的内置Error 类,创建了几种特定于应用程序的错误类型,如 VisionAgentError、ValidationError、FileProcessingError、ApiRequestError和ConfigurationError。每种错误类型都包含一个错误代码、可选的状态码和详细信息。此外,还实现了一些辅助函数,用于从错误对象创建API 错误响应、创建验证结果以及格式化用户友好的错误消息。
/** * 视觉代理的基础错误类,继承自原生Error */ export class VisionAgentError extends Error { public readonly code: string; public readonly status?: number; public readonly details?: Record<string, unknown>; /** * 创建一个VisionAgentError实例 * @param message 错误消息 * @param code 错误代码 * @param status HTTP状态码(可选) * @param details 错误详情(可选) */ constructor(message: string, code: string, status?: number, details?: Record<string, unknown>) { super(message); this.name = 'VisionAgentError'; this.code = code; this.status = status; this.details = details; } } /** * 验证错误类,表示数据验证失败 */ export class ValidationError extends VisionAgentError { /** * 创建一个ValidationError实例 * @param message 错误消息 * @param details 错误详情(可选) */ constructor(message: string, details?: Record<string, unknown>) { super(message, 'VALIDATION_ERROR', 400, details); this.name = 'ValidationError'; } } /** * 文件处理错误类,表示文件处理过程中发生的错误 */ export class FileProcessingError extends VisionAgentError { /** * 创建一个FileProcessingError实例 * @param message 错误消息 * @param details 错误详情(可选) */ constructor(message: string, details?: Record<string, unknown>) { super(message, 'FILE_PROCESSING_ERROR', 400, details); this.name = 'FileProcessingError'; } } /** * API请求错误类,表示API请求过程中发生的错误 */ export class ApiRequestError extends VisionAgentError { /** * 创建一个ApiRequestError实例 * @param message 错误消息 * @param status HTTP状态码 * @param details 错误详情(可选) */ constructor(message: string, status: number, details?: Record<string, unknown>) { super(message, 'API_REQUEST_ERROR', status, details); this.name = 'ApiRequestError'; } } /** * 配置错误类,表示配置相关的错误 */ export class ConfigurationError extends VisionAgentError { /** * 创建一个ConfigurationError实例 * @param message 错误消息 * @param details 错误详情(可选) */ constructor(message: string, details?: Record<string, unknown>) { super(message, 'CONFIGURATION_ERROR', 500, details); this.name = 'ConfigurationError'; } } /** * 将错误转换为ApiError对象 * @param error 要转换的错误 * @returns 转换后的ApiError对象 */ export function createApiError(error: unknown): ApiError { if (error instanceof VisionAgentError) { return { code: error.code, message: error.message, status: error.status, details: error.details }; } if (error instanceof Error) { return { code: 'UNKNOWN_ERROR', message: error.message, status: 500 }; } return { code: 'UNKNOWN_ERROR', message: '发生未知错误', status: 500, details: { originalError: error } }; } /** * 创建验证结果对象(成功情况) * @param success 表示验证是否成功(此处为true) * @param data 验证成功时返回的数据 * @returns 验证结果对象 */ export function createValidationResult<T>(success: true, data: T): ValidationResult<T>; /** * 创建验证结果对象(失败情况) * @param success 表示验证是否成功(此处为false) * @param error 验证失败时的错误消息 * @returns 验证结果对象 */ export function createValidationResult<T>(success: false, error: string): ValidationResult<T>; /** * 创建验证结果对象的实现 * @param success 表示验证是否成功 * @param dataOrError 验证成功时的数据或验证失败时的错误消息 * @returns 验证结果对象 */ export function createValidationResult<T>(success: boolean, dataOrError: T | string): ValidationResult<T> { if (success) { return { success: true, data: dataOrError as T }; } else { return { success: false, error: dataOrError as string }; } } /** * 判断错误是否为Axios错误 * @param error 要检查的错误 * @returns 如果是Axios错误则返回true,否则返回false */ export function isAxiosError(error: unknown): error is { isAxiosError: true; response?: { status: number; data: unknown } } { return typeof error === 'object' && error !== null && 'isAxiosError' in error && error.isAxiosError === true; } /** * 格式化错误信息,使其更适合展示给用户 * @param error 要格式化的ApiError对象 * @returns 格式化后的错误字符串 */ export function formatErrorForUser(error: ApiError): string { let message = `错误 ${error.code}`; if (error.status) { message += ` (${error.status})`; } message += `: ${error.message}`; if (error.details && Object.keys(error.details).length > 0) { const detailsStr = Object.entries(error.details) .map(([key, value]) => `${key}: ${String(value)}`) .join(', '); message += ` (${detailsStr})`; } return message; }(2)文件src/utils/file.ts是一个文件处理相关的工具函数集合,主要功能包括:检测文件类型,验证文件大小和类型是否符合要求,将文件转换为Base64编码(支持从本地路径或远程URL获取文件,对图像还会进行裁剪、格式转换等处理),从Base64字符串加载文件并确定内容类型和文件名,以及处理工具参数中的文件(包括下载远程文件到临时目录、转换为 Base64 等,最后清理临时文件),为视觉分析等操作提供文件预处理支持。
- 下面这段代码的功能是将文件转换为Base64编码,支持从远程URL或本地路径获取文件,对图像文件会进行裁剪、格式转换等处理,同时会验证文件类型和大小是否符合要求。原理是先判断文件来源(远程或本地),获取文件缓冲区后,根据文件类型进行相应处理,图像文件用sharp库处理后转换为Base64,其他类型直接转换。
export async function fileToBase64(input: string, options: LoadFileOptions = {}): Promise<string> {
try {
//处理文件前验证文件类型
const fileType = options.fileType || detectFileType(input);
validateFileType(input, fileType);
let buffer: Buffer;
if (input.startsWith('http://') || input.startsWith('https://')) {
//从远程URL获取文件
const response = await axios.get(input, {
responseType: 'arraybuffer',
timeout: FILE_LIMITS.DOWNLOAD_TIMEOUT_MS,
maxContentLength: getMaxSizeForFileType(fileType)
});
buffer = Buffer.from(response.data, 'binary');
} else {
//验证本地文件是否存在且可读
await fs.promises.access(input, fs.constants.R_OK);
buffer = await fs.promises.readFile(input);
}
//验证文件大小
validateFileSize(buffer, fileType);
switch (fileType) {
case 'image':
//对图像进行处理:移除透明度、调整大小、转换为png格式
const processedImage = await sharp(buffer)
.removeAlpha()
.resize({
width: options.sharpOptions?.formatOptions?.width as number || undefined,
height: options.sharpOptions?.formatOptions?.height as number || undefined,
fit: 'inside',
withoutEnlargement: true
})
.toFormat('png')
.toBuffer();
return processedImage.toString('base64');
default:
//非图像文件直接转换为Base64
return buffer.toString('base64');
}
} catch (err) {
if (err instanceof Error) {
throw new Error(`处理文件'${input}'失败: ${err.message}`);
} else {
throw new Error(`处理文件'${input}'失败:未知错误`);
}
}
}
- 下面这段代码的功能是从Base64字符串加载文件,确定文件的缓冲区、文件名、内容类型等信息。原理是先处理Base64字符串,去除数据头,转换为缓冲区,然后验证文件大小(如果指定了文件类型),再确定内容类型和文件名,最后返回包含这些信息的对象。
export function loadFileFromBase64(
base64String: string,
options: LoadFileOptions
): LoadedFile {
//检查Base64字符串是否有效
if (!base64String || typeof base64String !== 'string') {
throw new Error('提供的Base64字符串无效');
}
//移除Base64字符串的数据头
const base64Data = base64String.replace(/^data:([^;]+);base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
//如果指定了文件类型,验证文件大小
if (options.fileType) {
validateFileSize(buffer, options.fileType);
}
//确定内容类型
let contentType = options.contentType;
if (!contentType) {
contentType = detectContentTypeFromBase64(base64Data, options.fileType);
}
//确定文件名
let filename = options.filename || 'file';
if (!filename.includes('.')) {
const extension = getExtensionFromContentType(contentType);
filename = `${filename}.${extension}`;
}
return {
buffer,
filename,
contentType,
originalSize: buffer.length
};
}
- 下面这段代码的功能是处理工具参数中的文件,包括下载远程文件到临时目录、将文件转换为 Base64等,处理完成后清理临时文件。原理是遍历工具参数中的不同文件类型,对每个文件进行来源判断(本地或远程),远程文件下载到临时目录后转换为Base64,处理完成后通过finally块清理临时文件。
export async function processFileArgs(toolArgs: JsonObject): Promise<JsonObject> {
const fileTypeMap = {
'images': 'image',
'pdfs': 'pdf',
'videos': 'video'
} as const;
const allFileTypes = ['image', 'video', 'pdf', 'images', 'pdfs', 'videos'] as const;
if (!toolArgs['requestBody'] || typeof toolArgs['requestBody'] !== 'object') {
return toolArgs;
}
//跟踪临时文件以便清理
const tempFiles: string[] = [];
try {
for (const fileType of allFileTypes) {
if (fileType in toolArgs['requestBody']) {
const isSingular = ['image', 'video', 'pdf'].includes(fileType);
const isPluralArray = Array.isArray(toolArgs['requestBody'][fileType]);
if (isSingular || !isPluralArray) {
let url = toolArgs['requestBody'][fileType] as string;
if (url?.startsWith('@')) {
url = url.slice(1);
}
//处理远程URL
if (url?.startsWith('http://') || url?.startsWith('https://')) {
url = await downloadToTemp(url, fileType);
tempFiles.push(url);
}
if (url) {
const normalizedPath = path.normalize(url);
if (!path.isAbsolute(normalizedPath)) {
throw new Error(`请为${fileType}提供全局(绝对)文件路径,而不是本地路径。`);
}
const conversionFileType = isSingular ? fileType as FileType : fileTypeMap[fileType as keyof typeof fileTypeMap];
const fileBase64 = await fileToBase64(url, { fileType: conversionFileType });
toolArgs['requestBody'][fileType] = fileBase64;
}
} else if (isPluralArray) {
const files = toolArgs['requestBody'][fileType] as string[];
//验证数组大小
if (files.length > FILE_LIMITS.MAX_FILES_IN_ARRAY) {
throw new Error(`在${fileType}数组中文件数量过多。允许的最大数量:${FILE_LIMITS.MAX_FILES_IN_ARRAY},提供的数量:${files.length}`);
}
const conversionFileType = fileTypeMap[fileType as keyof typeof fileTypeMap];
const processedFiles: string[] = [];
for (let i = 0; i < files.length; i++) {
let url = files[i];
if (url?.startsWith('@')) {
url = url.slice(1);
}
//处理远程URL
if (url?.startsWith('http://') || url?.startsWith('https://')) {
url = await downloadToTemp(url, fileType);
tempFiles.push(url);
}
if (url) {
const normalizedPath = path.normalize(url);
if (!path.isAbsolute(normalizedPath)) {
throw new Error(`请为${fileType}[${i}]提供全局(绝对)文件路径,而不是本地路径。`);
}
const fileBase64 = await fileToBase64(url, { fileType: conversionFileType as FileType });
processedFiles.push(fileBase64);
}
}
toolArgs['requestBody'][fileType] = processedFiles;
}
}
}
return toolArgs;
} finally {
//清理临时文件
await Promise.allSettled(
tempFiles.map(async (tempFile) => {
try {
await fs.promises.unlink(tempFile);
} catch (error) {
console.warn(`清理临时文件失败:${tempFile}`, error);
}
})
);
}
}
- 下面这段代码的功能是从远程URL下载文件到临时目录。原理是使用fetch方法获取远程文件,验证下载文件的大小是否符合对应文件类型的限制,然后将文件内容写入临时目录,返回临时文件路径。
async function downloadToTemp(url: string, fileType: string): Promise<string> {
try {
//从远程URL获取文件
const response = await fetch(url, {
signal: AbortSignal.timeout(FILE_LIMITS.DOWNLOAD_TIMEOUT_MS)
});
//检查响应是否成功
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
//获取文件大小并验证
const contentLength = response.headers.get('content-length');
if (contentLength) {
const size = parseInt(contentLength, 10);
const detectedFileType = detectFileType(url);
const maxSize = getMaxSizeForFileType(detectedFileType);
if (size > maxSize) {
throw new Error(`下载大小${(size / (1024 * 1024)).toFixed(2)}MB超过了${detectedFileType}文件的限制`);
}
}
//将响应转换为缓冲区
const buffer = await response.arrayBuffer();
validateFileSize(Buffer.from(buffer), detectFileType(url));
//确定临时文件扩展名
const ext = getFileExtension(fileType);
const tempPath = path.join(os.tmpdir(), `vision_agent_temp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}${ext}`);
//将文件写入临时目录
await fs.promises.writeFile(tempPath, Buffer.from(buffer));
return tempPath;
} catch (error) {
if (error instanceof Error) {
throw new Error(`下载${url}失败: ${error.message}`);
}
throw new Error(`下载${url}失败:未知错误`);
}
}
(3)文件src/utils/http.ts的功能是对HTTP请求或响应中的数据进行安全处理,主要用于日志记录时脱敏敏感信息(如API密钥、令牌、密码等),同时限制日志内容的长度,防止敏感数据泄露并控制日志体积。处理逻辑会根据数据类型(字符串、对象或其他类型)采取不同的脱敏策略,确保关键信息被替换为[REDACTED]。
if (typeof data === 'string') { // 移除潜在的敏感信息 return data .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [REDACTED]') // 脱敏Bearer令牌 .replace(/Basic\s+[A-Za-z0-9+/=]+/g, 'Basic [REDACTED]') // 脱敏Basic认证信息 .replace(/"apiKey"\s*:\s*"[^"]+"/g, '"apiKey": "[REDACTED]"') // 脱敏apiKey字段 .substring(0, HTTP_LIMITS.MAX_RESPONSE_LOG_LENGTH); // 限制日志长度 } if (typeof data === 'object' && data !== null) { try { // 序列化对象时脱敏敏感字段(包含key、token、password、secret的字段) const sanitized = JSON.stringify(data, (key, value) => { if (typeof key === 'string' && (key.toLowerCase().includes('key') || key.toLowerCase().includes('token') || key.toLowerCase().includes('password') || key.toLowerCase().includes('secret'))) { return '[REDACTED]'; } return value; }); return sanitized.substring(0, HTTP_LIMITS.MAX_RESPONSE_LOG_LENGTH); // 限制日志长度 } catch { return '[无法序列化对象]'; } } // 其他类型数据转为字符串后限制长度 return String(data).substring(0, HTTP_LIMITS.MAX_RESPONSE_LOG_LENGTH);