通过实现一个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的日志的问题,麻烦教一教 。

相关推荐
Liu.7742 小时前
uniappx鸿蒙适配
前端
山有木兮木有枝_3 小时前
从代码到创作:探索AI图片生成的神奇世界
前端·coze
言兴3 小时前
秋招面试---性能优化(良子大胃袋)
前端·javascript·面试
WebInfra4 小时前
Rspack 1.5 发布:十大新特性速览
前端·javascript·github
雾恋5 小时前
我用 trae 写了一个菜谱小程序(灶搭子)
前端·javascript·uni-app
烛阴5 小时前
TypeScript 中的 `&` 运算符:从入门、踩坑到最佳实践
前端·javascript·typescript
Java 码农6 小时前
nodejs koa留言板案例开发
前端·javascript·npm·node.js
ZhuAiQuan7 小时前
[electron]开发环境驱动识别失败
前端·javascript·electron
nyf_unknown7 小时前
(vue)将dify和ragflow页面嵌入到vue3项目
前端·javascript·vue.js
胡gh7 小时前
浏览器:我要用缓存!服务器:你缓存过期了!怎么把数据挽留住,这是个问题。
前端·面试·node.js