通过实现一个mcp-server来理解mcp

前情提要

关于mcp的所有内容都可以在官网找到:
mcp官网

接下来会简要的解释mcp的用处,以及实现一个简单mcp-server。

mcp能做什么

chagpt 刚出的时候,你的朋友阿光来问你,他想压缩一个文件夹下的图片,能不能直接跟chatgpt说他想压缩某个文件夹下的所有图片,然后chatgpt 就帮他压缩。当时那当然是不太行,但是你跟他说,没事,我帮你写一个脚本,你执行一下就可以了。于是你写了一个叫yasuotupian的脚本,只需要执行:

bash 复制代码
npx yasuotupian -d /User/私人/学习资料/图片

这样就可以自动找到阿光这个文件夹下的全部图片然后进行压缩。

但是阿光不满意,觉得以后要是电脑内存不够了,要压缩 /User/私人/学习资料/视频 里的内容的话,那没准又要多记一个yasuoshipin的命令,太麻烦了。于是你没有办法,你只好给他做了个网页,让他把图片和视频拖进去然后执行压缩,顺便趁他上传的时候偷到了他全部的学习资料

现在有了mcp ,阿光就可以实现他想要的功能了。mcp 做的事情一句话描述就是让模型去调用你写的这个脚本

步骤大致如下(我猜的):

  1. 阿光告诉mcp客户端(我们后面以cursor 为例,mcp 客户端的案例我们下一节再写),我要压缩/User/私人/学习资料/图片 下的图片
  2. 客户端处理好这句话的内容,理解到你的需求是压缩, 并且路径是 /User/私人/学习资料/图片
  3. 客户端看一眼自己的mcp-server 的配置,发现有这么个合适的server里面有一个叫做tupianyasuotool,顺便看一眼这个tool有一个参数path
  4. 调用这个tool,并且把路径当作实参传给path

那么我们就着手编写这个mcp-server并进行配置。

实现压缩工具

为了写mcp服务,我们需要安装一些必要的工具 当然,这些基本步骤mcp官网也有写,看这里

他这个是保姆级教程了,我们按照他的要求创建好文件夹,安装好必要的工具。由于我们不是要实现天气查询mcp,所以我们只要做到安装依赖那一步就可以收手了。

接下来在src文件夹下创建下面几个文件:

index文件如下,这个是入口文件,代码格式基本固定:

ts 复制代码
import { server } from './server.js';
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

server文件如下,格式也非常固定:

ts 复制代码
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { setupTool } from "./tool.js";

const server = new McpServer({
  name: "<your-server-name>",
  version: "1.0.0",
  capabilities: {
    resources: {},
    tools: {},
  },
});

setupTool(server);

export { server } 

tool文件格式如下,格式也非常固定

ts 复制代码
// @ts-ignore
import { z } from 'zod';
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { run } from "./run.js";

// 定义输入参数的schema
const InputSchema = {
  
}

// 定义返回值的schema
const OutputSchema = {
  
}


function setupTool(server: McpServer) {
  server.tool(
    "<name>",
    "<description>",
    InputSchema,
    async (params: any) => {
      const result = await run();
      return {
        content: [
          { type: "text", text: result }
        ]
      };
    }
  );
}

export { setupTool }

那么接下来就是实现真正的执行压缩的文件了,也就是run.ts的内容:

ts 复制代码
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';

const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];

// 定义压缩结果接口
interface CompressResult {
  success: boolean;
  originalSize?: number;
  compressedSize?: number;
  error?: string;
}

// 定义运行结果接口
interface RunResult {
  success: boolean;
  message: string;
  compressedCount?: number;
  failedCount?: number;
  originalSize?: number;
  compressedSize?: number;
  compressionRatio?: string;
}

// 递归遍历文件夹,返回所有图片文件 
function getAllImages(folderPath: string): string[] {
  const images: string[] = [];
  function traverse(folderPath: string) {
    const files = fs.readdirSync(folderPath);
    for (const file of files) {
      const filePath = path.join(folderPath, file);
      const stats = fs.statSync(filePath);
      if (stats.isDirectory()) {
        traverse(filePath);
      } 
      else if (imageExtensions.includes(path.extname(file).toLowerCase())) {
        images.push(filePath);
      }
      else {
        // nothing to do 不要push进入images
      }
    }
  }
  traverse(folderPath);
  return images;
}

// 使用sharp压缩图片
// 图片保持原有格式和大小
// 仅做质量压缩
async function compressImage(imagePath: string, outputPath: string, quality: number): Promise<CompressResult> {
  try {
    // 获取原始文件大小
    const stats = fs.statSync(imagePath);
    const originalSize = stats.size;
    
    // 获取文件扩展名
    const ext = path.extname(imagePath).toLowerCase();
    
    // 创建sharp实例
    let sharpInstance = sharp(imagePath);
    
    // 根据不同格式设置压缩选项
    switch (ext) {
      case '.jpg':
      case '.jpeg':
        sharpInstance = sharpInstance.jpeg({ quality });
        break;
      case '.png':
        sharpInstance = sharpInstance.png({ quality });
        break;
      case '.webp':
        sharpInstance = sharpInstance.webp({ quality });
        break;
      case '.gif':
        // GIF格式不支持quality参数,但可以通过colours和dither优化
        sharpInstance = sharpInstance.gif({ colours: 128, dither: 0.5 });
        break;
      default:
        // 对于其他格式,保持原格式并尝试压缩
        sharpInstance = sharpInstance.jpeg({ quality });
    }

    // 压缩图片
    await sharpInstance.toFile(outputPath);
    
    // 获取压缩后的文件大小
    const compressedStats = fs.statSync(outputPath);
    const compressedSize = compressedStats.size;
    
    return {
      success: true,
      originalSize,
      compressedSize,
    };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : String(error)
    };
  }
}

// 调用压缩
async function runCompress(folderPath: string, quality: number, keepOriginalName: boolean, outputPath: string): Promise<RunResult> {
  try {
    // 检查输入文件夹是否存在
    if (!fs.existsSync(folderPath)) {
      return {
        success: false,
        message: `文件夹不存在: ${folderPath}`
      };
    }
    
    // 确定输出文件夹路径
    const finalOutputPath = outputPath || `${folderPath}_compressed`;
    
    // 创建输出文件夹(如果不存在)
    if (!fs.existsSync(finalOutputPath)) {
      fs.mkdirSync(finalOutputPath, { recursive: true });
    }
    
    // 获取所有图片文件
    const images = getAllImages(folderPath);
    
    if (images.length === 0) {
      return {
        success: false,
        message: `在文件夹 ${folderPath} 中没有找到支持的图片文件`
      };
    }
    
    // 压缩所有图片
    let totalOriginalSize = 0;
    let totalCompressedSize = 0;
    let compressedCount = 0;
    let failedCount = 0;
    
    for (const imagePath of images) {
      const fileName = path.basename(imagePath);
      const outputFileName = keepOriginalName ? fileName : `compressed_${fileName}`;
      const outputFilePath = path.join(finalOutputPath, outputFileName);
      
      const result = await compressImage(imagePath, outputFilePath, quality);
      
      if (result.success) {
        totalOriginalSize += result.originalSize || 0;
        totalCompressedSize += result.compressedSize || 0;
        compressedCount++;
      } else {
        failedCount++;
      }
    }
    
    // 计算总体压缩率
    const compressionRatio = totalCompressedSize / totalOriginalSize * 100 + '%';
    
    return {
      success: true,
      message: `成功压缩 ${compressedCount}/${images.length} 个图片,失败 ${failedCount} 个,压缩率: ${compressionRatio}%`,
      compressedCount,
      failedCount,
      originalSize: totalOriginalSize,
      compressedSize: totalCompressedSize,
      compressionRatio: compressionRatio
    };
  } catch (error) {
    return {
      success: false,
      message: `压缩过程中出错: ${error instanceof Error ? error.message : String(error)}`
    };
  }
}

export { runCompress }

run文件的编写可以完全交给cursor去实现,当然如果仅仅是测试tool能不能运行的话,run函数直接return一个'hello world'也可以。

cursor配置

首先找到配置项

点进去写好配置

保存之后这样就运行起来了(这个配置文件win和mac可能会有区别)

接下来就可以在curosr中开Agent模式让他帮我们压缩文件了

这样就已经调用成功了,因为我这边要传多个参数,且质量参数为number类型,模型帮我做了两次参数调整。

调试

调试的话mcp文档也有,在这里

可能会遇到的报错

目前遇到一个,当tool执行的时候(也就是run文件中的代码执行的时候),如果有console.log,会报错JSON解析失败的错,注释掉console就好了。

大概就是这么多,如果有老哥解决了这个console.log的日志的问题,麻烦教一教 。

相关推荐
妄念鹿1 分钟前
关于tailwindcssV4版本官方插件没有提示
前端
七月丶2 分钟前
💬 打造丝滑交互体验:用 prompts 优化你的 CLI 工具(gix 实战)
前端·后端·github
愤怒的糖葫芦7 分钟前
异步编程进阶:Generator 与 Async/Await
前端·javascript
不想说话的麋鹿11 分钟前
「项目实战」从0搭建NestJS后端服务(八):静态资源访问以及文件上传
前端·node.js·全栈
liuxb11 分钟前
前端多标签主从管理方案分享
前端
小桥风满袖12 分钟前
Three.js-硬要自学系列3 (平行光与环境光、动画渲染循环、stats状态查看器)
前端·css
以前叫王绍洁13 分钟前
GIS 核心基础:地理坐标系与地图投影简明指南
前端·后端
11在上班14 分钟前
一句话解释「RPC调用网络」
前端·后端
北有花开15 分钟前
Android音视频-Lame编译(Android 15 16K)对齐 和 addr2line使用
android·前端·音视频开发
shoa_top16 分钟前
跨域问题
前端