一个demo,带你体验创建自己的MCP
FunctionCalling Tools
有时你是否会遇到这样的情况: 我给 LLM 写了数个类似的Tools:
scss
1. readFiles(path) // 读取整个文件
2. readFileLines(Path,start,end) // 读取指定行
3. getFileHead(path,n) // 读取前n行
我问LLM:"帮我看一下 config.json里写了什么",结果它却只返回了前10行,货不对板。这三个工具描述都包含"读取文件",LLM无法自主区分该用哪个。
这就叫LLM的决策 paralysis,也就是决策瘫痪。LLM在面对相似的工具时,缺乏一个规范让它在指定情况下能选择到指定的工具。
或者是这样的情况: 在开发过程中,我给LLM写的 Tools越来越多,结果LLM反应的速度越来越慢。
例如我定义了三十多个工具,每当我问一个问题,LLM就会把这30个工具的完整定义(名称+描述+参数JSON Schema)都会一股脑塞进上下文,留给LLM"思考和推理"的空间被严重挤压
这就叫LLM的感知过载
此时,我多么希望,有一套规范,能让LLM在诸多工具中,高效准确的找到相应的工具,解决决策瘫痪与感知过载的问题啊
MCP-模型上下文协议 (Model Context Protocol)
- 制定一套标准,统一LLM与外部工具、系统和数据源的交互方式
- 工具被定义为服务器(MCPServer)上可执行的功能模块,通过MCP协议暴露给客户端,供LLM调用
核心流程
rust
客户端 -> 服务端 -> ^^ LLM -> 服务端 -> 客户端
| |
MCPserver
MCP的核心能力:
- 工具搜索,LLM不需要一次性知道所有的工具,而是按需发现有哪些工具以供使用
- 程序化工具调用,LLM判断需要某个工具后,程序化触发调用。
rust
LLM 返回 tool_calls -> 代码自动执行对应工具 -> 拿到结果再传回 LLM
- 工具使用示例,给LLM提供工具的使用返利,帮助它更准确地判断什么时候该用、怎么用
一个 MCP 服务端开发流程
我们的服务端用来接受前端的POST/chat,调用llm.chat()
事前准备,我们需要用到MCP,因此要pnpm下来
css
pnpm i @modelcontextprotocol/sdk
Schema规范
Schema规范是附在工具定义里给LLM看的说明书,让LLM知道调用某个工具时参数该怎么填。
也就是说,LLM返回的工具调用参数要遵从Schema
例如我给LLM传的数据,按API格式组织
yaml
{
model: 'qwen2.5:14b',
messages: [...],
tools: [...], // 工具定义里"包含"了 schema
stream: false
}
后续我们引入 zod 来帮我们写JSONS chema
事前引入、创建mcp server
javascript
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'
// 创建的mcpserver不会跑在某个端口上,而是寄生在某个进程上,等待客户端链接
const server = new McpServer({
name:'Demo',
version:'1.0.0',
})
讲解一下引入的包用来干啥:
- McpServer用来注册工具
server.tool()挂载工具函数- 客户端调取
listTools()的时候,他自动把注册过的工具清单返回给客户端,不需要我们手写返回逻辑。 - 客户端调用
callTool({name,arguments})的时候,自动根据name找到工具,返回结果
- transport用来跟进程交互
- 引入Zod,让JSON Schema的创建更加简洁且类型安全
- 引入的exec是为了后续创建一个子进程使用
注册一个工具函数,后续交给MCP
这里以一个简单的,读取当前目录下的文件工具函数。代码实现如下
javascript
server.tool('listFiles','列出执行目录下的文件',{path:z.string()},({path})=>{
return new Promise((resolve,reject)=>{
// 用exec开一个子进程,才能在命令行执行
exec(`dir ${path}`,(error,stdout,stderr)=>{
// 错误处理
if(error){
console.error(`执行命令出错:${error}`)
resolve({
content:[{type:'text',text:`执行命令出错:${error}`}]
})
return
}
if(stderr){
console.error(`执行命令出错:${stderr}`)
}
resolve({
content:[{type:'text',text:`已获取到目录${path}下的文件列表:\n\`\`\`${stdout}\`\`\n`}]
})
})
})
})
让MCPServer跟客户端通信
javascript
const transport = new StdioServerTransport() // transport 用来跟进程交互
await server.connect(transport)
console.log('MCP server connected')
transport是server跟外部通信的管道,基于stdin/stdout,客户端通过启动子进程+读写管道来跟server交互。server.connect(transport)就是把server接到这根管道上
至此,MCP的服务端就已经写好了
MCP client客户端
思路、事前引入
javascript
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
引入这些包,client是MCP客户端,我们在这里要做这样的事情:启动并连接MCP服务端,让外部代码能调用服务端的工具
client的核心作用,抛出创建客户端的函数
javascript
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)
console.log('MCP客户端连接成功')
}catch(error){
console.error(`MCP客户端连接失败: ${error}`)
throw error
}
return client
}
lib库,写一个llm.js来封装好并抛出一个类
事前准备,引入axios
我们引入在客户端抛出的创建客户端函数,并引入axios。axios是一个HTTP请求库,用来向LLM发POST请求
相当于浏览器的fetch,但更简洁,自动转换JSON,错误处理更方便
javascript
import { createClient } from '../mcp/client.js'
import axios from 'axios'
类的初始化
抛出一个类。进行初始化
kotlin
export class LLM {
mcpClient = createClient() // 类属性,创建 MCP 客户端实例(Promise)
constructor(model, base_url, api_key = null) {
this.model = model // 模型名,如 'qwen2.5:14b'
this.base_url = base_url // Ollama 地址,如 'http://localhost:11434'
this.api_key = api_key // API 密钥(我们用本地部署好的LLM不需要,所以默认 null)
}
headers属性
设定好 headers属性,用get可以作为一个对象属性调用
kotlin
get headers() {
if (this.api_key) {
return { 'Authorization': `Bearer ${this.api_key}` }
} else {
return { 'Content-Type': 'application/json' }
}
}
listTools 拿到工具列表
javascript
async listTools() {
return (await (await this.mcpClient).listTools()).tools.map(tool => ({
type: 'function',
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema // zod 生成的 JSON Schema
}
}))
}
这里有两层await
await this.mcpClient等待客户端连接成功await ...listTools()调MCP客户端拿工具列表
然后把MCP格式转换成Ollama要求的tools格式,你用其他的LLM则自行查阅文档
callTool 调用工具
让MCP客户端执行工具,返回结果包装成Ollama要求的tool消息格式,方便塞进messages数组
javascript
async callTool(tool_name, tool_args) {
const result = await (await this.mcpClient).callTool({
name: tool_name,
arguments: tool_args
})
return {
role: 'tool',
name: tool_name,
content: result.content[0].text // 取工具返回的文本
}
}
核心方法,chat
javascript
async chat(messages) {
const tools = await this.listTools() // ① 拿工具列表
const response = await axios.post(`${this.base_url}/api/chat`, {
model: this.model,
messages,
tools,
stream: false
}, { headers: this.headers })
const data = response.data
const reply = data.message.content // ② LLM 直接回复
const tool_calls = data.message.tool_calls // ③ LLM 要求调工具
if (tool_calls && tool_calls.length > 0) {
const toolResArr = tool_calls.map((tool_call) =>
this.callTool(tool_call.function.name, tool_call.function.arguments)
)
const results = await Promise.all(toolResArr) // ④ 并行执行所有工具
return await this.chat([...messages, ...results]) // ⑤ 递归
}
return reply // ⑥ 不需要工具了,返回最终回复
}
这里重点在于递归的思想。当LLM发现调用的工具无法满足需求,则会再次返回data.message.tool_calls,一直到需求解决为止。记得在5号的地方要写return,如果没有的话会丢弃最后一次回复,返回倒数第二个回复
执行流程
markdown
1. 拿工具列表
↓
2. 发给 Ollama(消息 + 工具列表)
↓
3. Ollama 返回两种可能:
├─ 直接回复 → 6. 返回
└─ 要调工具 → 4. 并行执行工具
↓
5. 把工具结果塞进 messages,递归调 chat()
↓
再次发给 Ollama,直到不再需要工具
MCP和LLM连接
- 服务端先借助MCPClient向MCPServer获取已注册的工具函数
- 配置好tools属性,与LLM通信
- 当LLM返回需要调用某些工具时,递归式的执行,重复向LLM通信,并合并工具执行结果
- 最终返回LLM最后一次的回复
应用环节,写一个后端实战
写一个Express后端服务,接受前端请求,调用LLM,返回结果。我们用apifox模拟前端发送请求(你也可以自己新建一个前端发送POST请求)
javascript
import express from 'express'
import axios from 'axios'
import { LLM } from '../lib/llm.js'
const app = express()
const llm = new LLM('qwen2.5:14b', 'http://localhost:11434')
// 用express解析POST传递的参数
app.use(express.json())
app.post('/chat', async (req, res) => {
// POST用body,GET用query
const { message } = req.body
const messages = [
{
role: 'system',
content: '你是一个ai助手,如果有需要,你可以调用tools函数来解决问题'
},{
role:'user',
content:message
}
]
const reply = await llm.chat(messages)
res.json({reply})
})
app.listen(3000,()=>{
console.log('server is running on port 3000')})
在终端中输入
json
Invoke-RestMethod -Uri "http://localhost:3000/chat" -Method Post -ContentType "application/json" -Body '{"message": "请调用工具列出 f:/AAAAIFullStackDevelop/ai/MCP/demo 这个目录下的文件"}' | ConvertTo-Json -Depth 5
可以模拟发送请求,得到

总结
MCP 让 LLM 能按标准协议发现并调用外部工具,本项目通过 Ollama 本地模型 + MCP 工具调用,实现了"AI 助手读取目录文件"的完整闭环。