一、什么是MCP?
MCP(Model Context Protocol),模型上下文协议,是用来制定一套标准,统一LLM与外部工具、系统和数据源的交互方式。最初,如何让LLM正确唤起对应的工具函数缺乏标准,LLM有时无法高效准确地找到对应的工具函数并使用,有些工具函数功能相似,容易选错,工具函数太多也会导致占用过多上下文(最初工具函数的描述是始终挂载在上下文中),MCP就解决了这个问题。
二、Tools 工具函数
前面说到了大模型调用工具函数,接下来再简单介绍一下工具函数是什么?
- 用户向大模型提问,大模型遇到自己无法解决的问题(比如用户问北京今天天气怎么样?),可以使用工具函数来获取内容,然后大模型再整合后给用户回复
举个例子,当我们问豆包"北京今天天气怎么样"时,可以看到他会先网页搜索,搜索得到了北京今天的天气情况后豆包就可以告诉你真实的天气情况,这个网页搜索就相当于工具函数,豆包并不知道天气这种具有实时性的信息,但遇到自己不知道的问题它可以通过网页搜索得到几个网页的结果,豆包整理之后再给你有效的回复。
三、在nodejs中从0开始搭建MCP服务端
要搭建一个MCP服务需要MCP客户端和MCP服务端,那我们如何在nodejs中搭建MCP服务端和客户端呢?
3.1 下载必要的包
先在终端输入npm i @modelcontextprotocol/sdk zod,把必要的包下载下来(zod 用来生成JSON Schema格式)
- 在 MCP 里,Zod 常被用来 定义工具(tool)的入参 schema 。MCP SDK 会把 Zod schema 转换成 JSON Schema 交给大模型,让模型知道该工具需要哪些参数、类型是什么,同时在服务端调用时对参数做校验,保证类型安全。
3.2 创建一个MCP服务
- 引入包。这里我们只写一个简单的工具函数,用于获取用户指定目录下的文件或文件夹,需要引入
exec,便于在运行终端命令dir来显示某个目录结构,还需引入McpServer、StdioServerTransport还有之前下载的zod
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js'
import {z} from 'zod'
import {exec} from 'child_process'
- 根据官方文档直接创建一个MCP Server
const server = new McpServer({
name:'Demo',
version:'1.0.0',
})
- 调用tool注册工具函数。传入四个参数:工具函数名称(
listFiles)、该工具函数描述、工具接收哪些参数和类型(需用Zod 类型)、回调函数,这里只写一个简单的执行dir指令的函数,也可以多写几个server.tool注册多个工具函数
//注册工具函数,需要传入path参数,类型为字符串
server.tool('listFiles','列出指定目录下的文件',{path:z.string()}, ({path}) => {
return new Promise((resolve, reject) => {
exec(`dir ${path}` ,(err, stdout, stderr) => {
if(err){
console.error(`执行命令出错:${err}`)
resolve({
content:[{type:'text',text:`执行命令出错:${err}`}]
})
return
}
if(stderr){
console.error(`执行命令出错stderr:${stderr}`)
}
resolve({content:[{type:'text',text:`已获取到目录${path}下的文件列表:\n${stdout}`}]})
})
})
})
- 开始从某一个进程(即将创建的MCP客户端)中接收消息和给该进程发送消息
const transport = new StdioServerTransport() //transport 用于在进程之间传递消息
await server.connect(transport)
console.log('mcp服务启动');
四、搭建MCP客户端
MCP服务需要一个MCP客户端与其建立连接,我们创建一个MCP客户端来测试我们的服务端
- 引入必要的包
import {Client} from '@modelcontextprotocol/sdk/client/index.js'
import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'
- 封装一个创建MCP客户端的函数,方便后续使用
export async function createClient(){
const client = new Client({
name:'Demo',
version:'1.0.0',
})
const transport = new StdioClientTransport({ // 定义可以启动MCPserver的指令
command:'node',
args:['mcp/server.js']
})
try{
await client.connect(transport) //与MCPServer建立连接(先自动执行了我们定义的指令)
console.log('mcp客户端启动');
}catch(err){
console.error(`mcp客户端连接失败:${err}`)
throw err
}
return client
}
- 注意:args里面最好是绝对路径,如果像我这样写相对路径则需要在mcp目录的上一级目录下才能运行
五、测试MCP连接
新建一个test.js文件来测试一下我们的MCP服务
- 引入我们之前封装的创建MCP客户端的函数
import {createClient} from './client.js'
- 创建MCPClient客户端,我们之前封装的
createClient函数内部会自动连接到MCPserver
const client = await createClient()
- 手动唤起工具函数(本来应该是LLM唤起,我们手动唤起测试一下),看能否返回server.js文件的目录
const result = await client.callTool({
name:'listFiles',
arguments:{
path:'server.js'
}
})
- 在终端将结果打印出来,能把server.js文件的目录打印出来说明MCP连接没问题
console.log('Server 提供的 tools:');
console.log(JSON.stringify(await client.listTools(),null,2));//打印工具函数列表
console.log('\n\n调用ListFiles:')
console.log(result)
六、用Ollama本地模式测试MCP
- 引入express和axios,用于创建node后端和向LLM发请求
import express from 'express'
import axios from 'axios'
- 定义一个post接口,前端可以向该端口发post请求,我们再封装一个
llm.chat函数,使得调用改函数可以将messages传给大模型,并且判断是否需要执行工具函数,然后最终得到LLM的结果
const app = express()
app.use(express.json()) //让express能够解析post传递的参数,express只能解析get传递的参数
app.post('/chat',async(req,res) => {
const {message} = req.body//post用req.body获取参数,get用req.query获取参数
const messages = [
{role:'system',content:'你是一个AI助手,如果有需要,你可以借助tools函数来完成任务'},
{role:'user',content:message}
]
const reply = await llm.chat(messages)//封装一个llm.chat函数
res.json({reply})
})
- 新建一个llm.js文件,用来封装我们需要的这个
llm.chat函数,并引入createClient和axios
import { createClient } from '../mcp/client.js'
import axios from 'axios'
- 为使代码美观,我们封装一个LLM类,需要传入模型名、通讯地址、apikey(本地模型不需要)。再提前封装好几个函数,使得与大模型通信时传入的都是大模型接受的标准格式
export class LLM {
mcpClient = createClient()
constructor (model, base_url, api_key = null) { //创建LLM类需要传入的参数
this.model = model
this.base_url = base_url
this.api_key = api_key
}
get headers() { //封装的请求头
if (this.api_key) {
return {
'Authorization': `Bearer ${this.api_key}`
}
} else {
return {
'Content-Type': 'application/json' //Ollama本地模型通信需要的请求头
}
}
}
async listTools() { //封装的listTools函数,列出工具函数列表,并使其返回大模型接受的标准格式
return (await (await this.mcpClient).listTools()).tools.map(tool => (
{
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema
}
}
))
}
async callTool(tool_name, tool_args) {//封装的callTool函数,唤起指定工具函数,并且返回大模型接受的标准格式
const result = await (await this.mcpClient).callTool({
name: tool_name,
arguments: tool_args
})
return { //返回标准格式,让大模型知道该工具函数调用得到了什么结果
role: 'tool',
name: tool_name,
content: result.content[0].text //工具函数调用得到的结果
}
}
async chat (messages) {}
}
- 封装一个chat函数,与大模型进行通信,并且判断是否需要调用工具函数,递归执行(大模型调用完工具函数后还是可能会无法回复用户,还需要调用工具函数)
async chat (messages) {
const tools = await this.listTools() // [工具函数的表述]
const response = await axios.post(`${this.base_url}/api/chat`, {//向LLM发请求(只能是post请求)
model: this.model, //请求体
messages,
tools,
stream: false
}, {
headers: this.headers //请求头
})
const data = response.data
const reply = data.message.content // LLM本身能解决问题,得到返回的结果
//如果LLM不能解决问题,LLM返回的数据中的tool_calls就会有值,说明LLM需要工具函数
const tool_calls = data.message.tool_calls // LLM不能解决问题,说需要工具函数
if (tool_calls && tool_calls.length > 0) {
const toolResArr = tool_calls.map((tool_call) => (
// .map遍历触发LLM需要的所有工具函数
this.callTool(tool_call.function.name, tool_call.function.arguments)
))
const results = await Promise.all(toolResArr) // 需要等待全部工具函数执行完成再进行下一步
// 再次跟LLM通信,将工具的结果传给LLM
return await this.chat([...messages, ...results])
}
return reply
}
- 回到刚才的代码,引入我们封装的LLM类并创建实例(Ollama的通信地址是http://localhost:11434)
import express from 'express'
import axios from 'axios'
import {LLM} from '../lib/llm.js'
const app = express()
const llm = new LLM('qwen3.5:4b','http://localhost:11434')
- 这样我们就可以通过调用llm.chat来处理前端发送的message,并返回大模型的回复了
app.post('/chat',async(req,res) => {
const {message} = req.body//post用req.body获取参数,get用req.query获取参数
const messages = [
{role:'system',content:'你是一个AI助手,如果有需要,你可以借助tools函数来完成任务'},
{role:'user',content:message}
]
const reply = await llm.chat(messages)
res.json({reply})
})
app.listen(3000,() => { //在3000端口运行该后端
console.log('server is running on port 3000')
})
- 接下来我们通过Apifox平台测试一下我们的后端(注意请求地址后要接/chat,并保持Ollama为启动状态),给后端发一个post请求message
- 可以看到本地LLM正确回答了我们的问题
七、总结
- 服务端先借助MCPClient 向MCPServer 获取已注册的工具函数
- 跟LLM通信并配置好 tools 属性
- 当模型返回需要调用XX工具时,递归式的执行重复向LLM通信,并合并messages
- 最终返回出和 LLM 最后一次通信的结果