MCP开发实例

简介

mcp虽然和http类似都是协议,但是不能和http一样在前端页面直接使用js的fetch去调用,它必须依赖一个客户端去才能使用,比如cursor、vscode、Trae这类客户端。
以cursor为例:
没安装mcp时,我们和它对话,它会根据上下文去调用模型然后返回答案。
安装了mcp时,我们和它对话,它会先去mcp工具集合中查看有没有解决用户问题的mcp服务,如果命中了(每一个mcp服务都有一个描述,这个描述是mcp开发者自己定义的,如果你的mcp服务的描述和用户的问题最贴切,那么客户端就会使用你的mcp服务解决后续的问题),就会先调用mcp服务拿到数据,然后会把这些数据丢给模型,模型再结合用户问题和mcp返回的数据回答问题。
比如用户提问“代码为12345的发票是真实存在的吗”,那模型本身肯定是不知道的,因为查询发票真伪需要去税局官网查询,模型不会登陆网站去操作。
image
现在你安装了一个查询发票真假的mcp服务,描述为“验证发票真伪”,那么当用户再次提问上面的问题时,模型会认为你的mcp描述最贴近用户的提问,就会调用你的mcp服务,然后mcp服务会在用户的提问中提取出需要的参数(比如发票代码),再使用参数调用后端的http查询接口,拿到数据后返回给模型。
image

mcp的调用方式

mcp的调用方式有两种,一种就是npx,一种是sse,
以下以cursor为例说明。

1. 先说sse。

sse模式需要开发者自己搭建一个服务器部署mcp,这个mcp服务向外暴露2个接口,
一个是保持长链接的sse接口,这个接口负责向客户端输出回答的文字流,这个链接是一直保持的,从客户端链接到服务开始,直到客户端主动断开连接结束。
一个是message接口,用来接收客户端发来的问题请求。
之所以需要2个接口,是因为sse在响应客户端请求后,就总是单向的向客户端传输数据了,所以如果后续用户问问题,都是通过message接口向mcp服务发送,然后mcp的回答再通过sse传输给客户端。
sse这种方式过时了,Streamable Http可以替换sse,不过我在开发的时候还没有Streamable Http。
1.1 安装 Cursor
1.2 进入 Cursor 设置界面配置 SSE 连接
image
1.3 添加一个新的 MCP Server 配置
image
1.4 返回 Cursor 设置界面查看 MCP 服务工具状态
image
1.5 选择配置 Cursor 大模型让你拥有更好的服务体验,垃圾模型可能无法命中mcp服务,建议选择claude-3.7-sonnet 及其以上的模型
image
1.6 模型交互模式 :选择 Agent 方式,按下 CTRL/CMD + L 快捷键,即可在编辑器右侧打开对话框
image

2. 再说npx

这种模式相当于让客户端用户在本机启动一个node服务,这个服务就是mcp服务,这样的好处是不会像sse那样可能断链,服务稳定,并且开发者可以省下服务器资源。
2.1 安装 Node.js 下载适用于操作系统的 Node 应用程序
注意:
① 请确保已安装 Node.js,并检查本地 Node.js 版本是否为 v22.12.0 或更高版本。建议下载使用 v22.12.0 及以上版本以获得最佳兼容性和性能。
② 检查 npm 镜像源是否为默认镜像源(https://registry.npmjs.org/)
查看命令:
npm config get registry
2.2 安装 Cursor,建议使用最新版本的 Cursor 客户端,安装 Cursor。然后登录。
2.3进入 Cursor 设置界面配置 MCP Server
image
2.4添加一个新的 MCP Server 配置
image
2.5返回 Cursor 设置界面查看 MCP 服务工具状态,绿灯,表示工具状态正常可用。
image
当出现 Client closed 异常时,可以点击 开关(启用) 按钮以解决问题,或者重启按钮。
image
如果不能解决问题,请尝试:
① 更新cursor版本为最新,退出所有cursor编辑器,
然后重新打开cursor。
② 确认node版本为22.12以上。
③ 执行 npx clear-npx-cache 命令,然后重启。
④ 确认配置文件没有拼写错误。
2.6 选择配置 Cursor 大模型让你拥有更好的服务体验,建议选择 claude-3.7-sonnet 及其以上的模型。
2.7 模型交互模式 :选择 Agent 方式,按下 CTRL/CMD + L 快捷键,即可在编辑器右侧打开对话框。

以下为开发实例

开发时,一个工程要包括开发和生产环境,那么需要解决本地开发时如何与cursor等客户端连接的问题,这些问题的解决办法都在最后的开发文档中。以下的内容包括完整的开发和生产代码。
项目目录
markdown
├── .gitignore # Git 忽略文件配置 ├── README.md # 项目说明文档(英文) ├── 开发文档.md # 开发文档(中文) ├── package.json # Node.js 项目配置文件 ├── tsconfig.json # TypeScript 编译配置 ├── index.ts # NPX 模式主入口文件 ├── index-sse.ts # SSE 模式主入口文件 ├── dist/ # 编译输出目录 └── docker/ # Docker 部署相关文件 ├── Dockerfile # Docker 镜像构建文件 └── entrypoint.sh # Docker 容器启动脚本
package.json
json
{ "name": "bw-mcp-server-invoice-check", "version": "1.1.2", "description": "MCP server for invoice check", "license": "MIT", "type": "module", "bin": { "mcp-server-invoice-check": "dist/index.js" }, "files": [ "dist/index.js", "index.ts" ], "main": "dist/index.js", "scripts": { "build:npx": "tsc index.ts --target ES2022 --module Node16 --outDir dist && shx chmod +x dist/*.js", "prepare": "npm run build:npx", "build": "npm run build:npx", "watch": "tsc --watch", "build:sse": "tsc index-sse.ts --target ES2022 --module Node16 --outDir dist && shx chmod +x dist/*.js", "dev:sse": "npx tsx --watch index-sse.ts", "start:sse": "node dist/index-sse.js" }, "dependencies": { "@modelcontextprotocol/sdk": "1.10.2", "express": "^5.1.0", "ts-node": "^10.9.2", "zod": "^3.24.3" }, "devDependencies": { "@types/express": "^5.0.1", "@types/node": "^22", "shx": "^0.3.4", "typescript": "^5.6.2" } }
tsconfig.json
json
{ "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "outDir": "./dist", "rootDir": "." }, "include": [ "./**/*.ts" ] }
index.ts
js
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; function getApiKey() { const apiKey = process.env.INVOICE_CHECK_API_KEY; if (!apiKey) { console.error("INVOICE_CHECK_API_KEY environment variable is not set"); process.exit(1); } return apiKey; } const INVOICE_CHECK_API_KEY = getApiKey(); const INVOICE_VERIFICATION_TOOL = { name: "invoice_verification", description: "验证发票的真实性和合规性,通过识别数据是否准确和合法来完成验证。该工具帮助组织确保发票的完整性,支持多种发票类型,返回完整的详细信息以进行一致性验证。它能快速识别发票的真实性,以确保数据的准确性和合规性。支持增值税专用发票、普通发票、电子发票、全电发票 等14类票据的全字段核验,毫秒级返回真伪结果及完整票面信息。覆盖发票代码、号码、金额、开票日期、校验码、纳税人识别号 等关键字段校验", inputSchema: { type: "object", properties: { invoiceNumber: { type: "string", description: "发票号码(长度8位或20位)", }, billingDate: { type: "string", description: "开票日期(固定格式:YYYY-MM-DD)", }, checkCode: { type: "string", description: "校验码后六位(普票、电子发票时必填)", }, invoiceCode: { type: "string", description: "发票代码(长度10位或12位)", }, totalAmount: { type: "string", description: "合计金额 (例如金额为20,则输入20.00)", }, }, required: ["invoiceNumber", "billingDate"], }, }; const server = new Server( { name: "bw-servers/invoice-check", version: "0.1.0", }, { capabilities: { tools: {}, }, } ); async function invoiceCheck( invoiceCode: string, invoiceNumber: string, billingDate: string, checkCode: string, totalAmount: string ): Promise<string> { const params = { invoiceCode, invoiceNumber, billingDate, checkCode, totalAmount, userId: "bw", bizScene: "mcp", }; const url = new URL("https://xxx"); const response = await fetch(url.toString(), { method: "POST", headers: { "Content-Type": "application/json", "apiKey": INVOICE_CHECK_API_KEY, }, body: JSON.stringify(params), }); if (!response.ok) { throw new Error(`invoice-check API error: ${response.status} ${response.statusText}\n${await response.text()}`); } const data = await response.json(); if (data.status === 200) { return ` ID: ${data.data.id} InvoiceAssetId: ${data.data.invoiceAssetId} InvoiceType: ${data.data.invoiceType} InvoiceTypeCode: ${data.data.invoiceTypeCode} AdministrativeDivisionNo: ${data.data.administrativeDivisionNo} AdministrativeDivisionName: ${data.data.administrativeDivisionName} InvoiceCode: ${data.data.invoiceCode} InvoiceNumber: ${data.data.invoiceNumber} BillingDate: ${data.data.billingDate} PurchaserName: ${data.data.purchaserName} PurchaserTaxNo: ${data.data.purchaserTaxNo} PurchaserBank: ${data.data.purchaserBank} PurchaserAddressPhone: ${data.data.purchaserAddressPhone} SalesName: ${data.data.salesName} SalesTaxNo: ${data.data.salesTaxNo} SalesAddressPhone: ${data.data.salesAddressPhone} SalesBank: ${data.data.salesBank} TotalAmount: ${data.data.totalAmount} TotalTax: ${data.data.totalTax} AmountTax: ${data.data.amountTax} AmountTaxCn: ${data.data.amountTaxCn} Remarks: ${data.data.remarks} MachineCode: ${data.data.machineCode} CheckCode: ${data.data.checkCode} State: ${data.data.state} UploadDate: ${data.data.uploadDate} CheckCount: ${data.data.checkCount} CheckNumbers: ${data.data.checkNumbers} MonitorStatus: ${data.data.monitorStatus} CheckInvoiceDetailsList: ${JSON.stringify(data.data.checkInvoiceDetailsList)} `; } else { return `${data.message}`; } } server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [INVOICE_VERIFICATION_TOOL], })); server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; if (!args) throw new Error("No arguments provided"); switch (name) { case "invoice_verification": { const { invoiceCode, invoiceNumber, billingDate, checkCode, totalAmount } = args as { invoiceCode: string; invoiceNumber: string; billingDate: string; checkCode: string; totalAmount: string; }; const result = await invoiceCheck(invoiceCode, invoiceNumber, billingDate, checkCode, totalAmount); return { content: [{ type: "text", text: result }], isError: false, }; } default: return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true, }; } } catch (error) { return { content: [ { type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}`, }, ], isError: true, }; } }); async function runServer() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("invoice-check Search MCP Server running on stdio"); } runServer().catch((error) => { console.error("Fatal error running server:", error); process.exit(1); });
index-sse.ts
js
import express, { Request, Response } from 'express'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'; import { z } from 'zod'; import { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; /** * 这个示例服务器演示了已弃用的 HTTP+SSE 传输 * (协议版本 2024-11-05)。它主要用于测试向后兼容的客户端。 * * 服务器暴露两个端点: * - /sse: 用于建立 SSE 流 (GET) * - /messages: 用于接收客户端消息 (POST) * */ // 端口 const PORT = 8023; // SSE配置参数 const SSE_CONFIG = { heartbeatInterval: 15000, // 15秒发送一次心跳 headers: { Connection: 'keep-alive', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no', // 禁用Nginx缓冲 }, }; async function invoiceVerification({ invoiceCode, invoiceNumber, billingDate, checkCode, totalAmount, apiKey, }: { invoiceCode: string; invoiceNumber: string; billingDate: string; checkCode: string; totalAmount: string; apiKey: string; }): Promise<string> { const params = { invoiceCode, invoiceNumber, billingDate, checkCode, totalAmount, userId: 'bw', bizScene: 'mcp', }; console.log('response-params', params); const url = new URL( 'https://xxxx' ); const response = await fetch(url.toString(), { method: 'POST', headers: { 'Content-Type': 'application/json', apiKey, }, body: JSON.stringify(params), }); if (!response.ok) { throw new Error( `invoice-check API error: ${response.status} ${ response.statusText }\n${await response.text()}` ); } const data = await response.json(); console.log('response-data', data); return ` ID: ${data.data.id} InvoiceAssetId: ${data.data.invoiceAssetId} InvoiceType: ${data.data.invoiceType} InvoiceTypeCode: ${data.data.invoiceTypeCode} AdministrativeDivisionNo: ${data.data.administrativeDivisionNo} AdministrativeDivisionName: ${data.data.administrativeDivisionName} InvoiceCode: ${data.data.invoiceCode} InvoiceNumber: ${data.data.invoiceNumber} BillingDate: ${data.data.billingDate} PurchaserName: ${data.data.purchaserName} PurchaserTaxNo: ${data.data.purchaserTaxNo} PurchaserBank: ${data.data.purchaserBank} PurchaserAddressPhone: ${data.data.purchaserAddressPhone} SalesName: ${data.data.salesName} SalesTaxNo: ${data.data.salesTaxNo} SalesAddressPhone: ${data.data.salesAddressPhone} SalesBank: ${data.data.salesBank} TotalAmount: ${data.data.totalAmount} TotalTax: ${data.data.totalTax} AmountTax: ${data.data.amountTax} AmountTaxCn: ${data.data.amountTaxCn} Remarks: ${data.data.remarks} MachineCode: ${data.data.machineCode} CheckCode: ${data.data.checkCode} State: ${data.data.state} UploadDate: ${data.data.uploadDate} CheckCount: ${data.data.checkCount} CheckNumbers: ${data.data.checkNumbers} MonitorStatus: ${data.data.monitorStatus} CheckInvoiceDetailsList: ${JSON.stringify( data.data.checkInvoiceDetailsList )} `; } // 创建一个 MCP 服务器实例 const getServer = () => { const server = new McpServer( { name: 'bw-servers/invoice-verification', version: '0.1.0', }, { capabilities: { logging: {} } } ); const describe = ` 验证发票的真实性和合规性,通过识别数据是否准确和合法来完成验证。该工具帮助组织确保发票的完整性,支持多种发票类型,返回完整的详细信息以进行一致性验证。 它能快速识别发票的真实性,以确保数据的准确性和合规性。支持增值税专用发票、普通发票、电子发票、全电发票 等14类票据的全字段核验,毫秒级返回真伪结果及完整票面信息。 覆盖发票代码、号码、金额、开票日期、校验码、纳税人识别号 等关键字段校验。 `; server.tool( 'invoice_verification', describe, { invoiceNumber: z.string().describe('发票号码'), billingDate: z.string().describe('开票日期(固定格式:YYYY-MM-DD)'), checkCode: z .string() .describe('校验码后六位(普票、电子发票时必填)') .optional(), invoiceCode: z.string().describe('发票代码').optional(), totalAmount: z .string() .describe('合计金额(不含税金额)(例如金额为20,则输入20.00)') .optional(), }, async ( params, { sendNotification, sessionId } ): Promise<CallToolResult> => { // debugger; const { invoiceNumber, billingDate, checkCode = '', invoiceCode = '', totalAmount = '', } = params; if (!sessionId) { throw new Error('No sessionId provided'); } const apiKey = apiKeys[sessionId]; if (!apiKey) { throw new Error('No API key provided'); } // 发送初始通知 await sendNotification({ method: 'notifications/message', params: { level: 'info', data: `开始查询发票`, }, }); const result = await invoiceVerification({ invoiceNumber, billingDate, checkCode, invoiceCode, totalAmount, apiKey, }); return { content: [ { type: 'text', text: result, }, ], }; } ); return server; }; const app = express(); app.use(express.json()); // 按会话 ID 存储传输实例 const transports: Record<string, SSEServerTransport> = {}; const heartbeats: { [sessionId: string]: NodeJS.Timeout } = {}; const apiKeys: { [sessionId: string]: string } = {}; app.get('/health', (_: Request, res: Response) => { res.status(200).json({ status: 'healthy' }); }); // SSE 端点用于建立流 app.get('/mcp/ua/sse', async (req: Request, res: Response) => { console.log('收到 GET 请求到 /sse (建立 SSE 流)'); const apiKey = req.query.key as string; if (!apiKey) { res.status(400).send('必须提供API key'); return; } // 为客户端创建新的 SSE 传输实例 // POST 消息的端点是 '/messages' const transport = new SSEServerTransport('/mcp/ua/messages', res); // 设置响应头,防止中间代理缓存 Object.entries(SSE_CONFIG.headers).forEach(([key, value]) => { res.setHeader(key, value); }); // 设置超时时间为0(无限) req.socket.setTimeout(0); req.socket.setNoDelay(true); req.socket.setKeepAlive(true); // 按会话 ID 存储传输实例 const sessionId = transport.sessionId; transports[sessionId] = transport; apiKeys[sessionId] = apiKey; // 设置心跳,定期发送注释信息保持连接活跃 heartbeats[transport.sessionId] = setInterval(() => { if (res.writable) { res.write(`:data\n\n`); console.log(`心跳检测 ${transport.sessionId}`); } }, SSE_CONFIG.heartbeatInterval); const closeHandler = () => { // 清理心跳定时器 if (heartbeats[transport.sessionId]) { clearInterval(heartbeats[transport.sessionId]); delete heartbeats[transport.sessionId]; } console.log(`会话 ${sessionId} 的 SSE 传输已关闭`); delete transports[sessionId]; delete apiKeys[sessionId]; }; res.on('close', () => { console.log('res.on close 连接关闭 会话ID:', sessionId); closeHandler(); }); // 设置关闭处理程序以在关闭时清理传输实例 transport.onclose = () => { console.log('transport.onclose 连接关闭 会话ID:', sessionId); closeHandler(); }; try { // 将传输实例连接到 MCP 服务器 const server = getServer(); await server.connect(transport); console.log(`已建立 SSE 流,会话 ID: ${sessionId}`); } catch (error) { console.error('建立 SSE 流时出错:', error); console.log('server.connect 连接关闭 会话ID:', sessionId); closeHandler(); if (!res.headersSent) { res.status(500).send('建立 SSE 流时出错'); } } }); // 消息端点用于接收客户端 JSON-RPC 请求 app.post('/mcp/ua/messages', async (req: Request, res: Response) => { console.log('收到 POST 请求到 /messages'); // 从 URL 查询参数中提取会话 ID // 在 SSE 协议中,这是由客户端基于端点事件添加的 const sessionId = req.query.sessionId as string | undefined; if (!sessionId) { console.error('请求 URL 中未提供会话 ID'); res.status(400).send('缺少 sessionId 参数'); return; } const transport = transports[sessionId]; if (!transport) { console.error(`未找到会话 ID 对应的活动传输实例: ${sessionId}`); res.status(404).send('未找到会话'); return; } console.log('req.body', req.body); try { // 使用传输实例处理 POST 消息 await transport.handlePostMessage(req, res, req.body); } catch (error) { console.error('处理请求时出错:', error); if (!res.headersSent) { res.status(500).send('处理请求时出错'); } } }); // 启动服务器 app.listen(PORT, () => { console.log( `服务器启动,正在监听端口 ${PORT}` ); }); // 处理服务器关闭 process.on('SIGINT', async () => { console.log('正在关闭服务器...'); // 关闭所有活动的传输实例以正确清理资源 for (const sessionId in transports) { try { console.log(`正在关闭会话 ${sessionId} 的传输实例`); await transports[sessionId].close(); delete transports[sessionId]; } catch (error) { console.error(`关闭会话 ${sessionId} 的传输实例时出错:`, error); } } console.log('服务器关闭完成'); process.exit(0); });
docker/Dockerfile
bash
FROM node:18-alpine COPY . /opt/src/ WORKDIR /opt/src/ RUN npm config set registry https://registry.npmmirror.com/ RUN npm install --verbose --legacy-peer-deps RUN npm run build:sse CMD /bin/bash ./docker/entrypoint.sh
docker/startup.sh
bash
#!/bin/bash logs_root="/mnt/logs/${APP_NAME}" export NODE_ENV=$PRODUCT_ENV [ -d $logs_root ] || mkdir -p $logs_root node --version echo `date` && npm run start:sse
开发文档.md

分sse 和 npx 两个模块

sse

开发模式
bash
npm run dev:sse
如果想要本地查看打包后的真实代码效果,操作如下:
bash
npm run build:sse && npm run start:sse
本地开发时,mcp配置(注意配置key参数)
json
{ "mcpServers": { "bw-mcp-server-invoice-check": { "url": "http://localhost:8023/mcp/ua/sse?key=???" } } }
如何要测试测试环境的sse,mcp需要配置如下
json
{ "mcpServers": { "bw-mcp-server-invoice-check": { "url": "https://smartsk-api.baiwang.com/mcp/ua/sse?key=???" } } }

npx

不需要部署,只需要提交到npm官方仓库
  1. 开发时
bash
npm run watch
dist 目录下会有index.js,右击文件,复制路径
然后配置mcp客户端,比如cursor,把路径写入 args 中,env 为环境和变量配置
json
{ "mcpServers": { "invoice-check": { "command": "node", "args": ["/Users/baiwang/Desktop/work/mcp-sse-service-yst/dist/index.js"], "env": { "INVOICE_CHECK_API_KEY": "???" } } } }
  1. 开发完成后,使用命令检查发布npm的文件
bash
npm pack --dry-run
要发布的文件和预期一致就可以发布npm官方了
先 package.json 中 version 版本号自增
bash
登录 npm login 发布 npm publish
  1. 发布完成后本地使用
mcp配置
json
{ "mcpServers": { "bw-mcp-server-invoice-check": { "command": "npx", "args": ["-y", "bw-mcp-server-invoice-check"], "env": { "INVOICE_CHECK_API_KEY": "???" } } } }

遇到链接错误问题

  1. 更新cursor版本为最新,退出所有cursor编辑器,然后重新打开cursor。
  2. 确认node版本为22.12以上。
  3. 执行 npx clear-npx-cache 命令,然后重启。
  4. 确认配置文件没有拼写错误。