基于NodeJs实现一个MCP客户端(会话模式和无会话模式)

0 前言

前几天使用NodeJs对照官网文档实现了MCP服务端的开发(文章地址)最近尝试使用NodeJs实现一下MCP的客户端,感觉官网的示例代码对Client端描述得很少,以及对streamableHttp方式的传输的示例代码也很少,后面通过摸索以及对ai的询问终于走通并弄明白了Client 的会话模式和无会话模式。

1 搭建基本环境

1.1 创建项目文件,安装包管理工具,下载依赖

powershell 复制代码
# Create project directory
mkdir mcp-client-demo
cd mcp-client-demo

# Initialize npm project
npm init -y

# Install dependencies
npm install @modelcontextprotocol/sdk

# Install dev dependencies
npm install -D @types/node typescript

1.2 更新package.json文件

用于使用ES模块规范引入

javascript 复制代码
"type": "module"

1.3 下载nodemon

-g 任何项目直接使用

javascript 复制代码
npm i nodemon -g

新增nodemon.json

javascript 复制代码
//nodemon.json
{
    "watch": ["src"],
    "ext": "ts",
    "exec": "node --import 'data:text/javascript,import { register } from \"node:module\"; import { pathToFileURL } from \"node:url\"; register(\"ts-node/esm\", pathToFileURL(\"./\"));' ./src/index.ts"
}

1.4 新增tsconfig.json文件

typescript 复制代码
//tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src"],
}

2 创建 mcpClient 类

2.1 带会话模式的 mcpClient 类

2.1.1 对于MCP会话的相关解释

原文:Session Management

翻译:

MCP "会话"(session)指客户端与服务器之间存在逻辑关联的交互过程,该过程始于初始化阶段。为支持服务器建立有状态会话,需遵循以下规则:

使用流式 HTTP(Streamable HTTP)传输的服务器,可在初始化阶段分配会话 ID(session ID),并将其包含在承载 "初始化结果"(InitializeResult)的 HTTP 响应头Mcp-Session-Id中。

会话 ID 应具备全局唯一性且满足加密安全性要求(例如,通过安全方式生成的 UUID、JWT 令牌或加密哈希值)。

会话 ID 必须仅包含可见 ASCII 字符(字符编码范围为 0x21 至 0x7E)。

若服务器在初始化阶段返回了Mcp-Session-Id,则使用流式 HTTP 传输的客户端,必须在后续所有 HTTP 请求的Mcp-Session-Id头中携带该会话 ID。

要求客户端提供会话 ID 的服务器,对于未携带Mcp-Session-Id头的请求(初始化请求除外),应返回 HTTP 400 "错误请求"(Bad Request)响应。

服务器可在任意时间终止会话,会话终止后,对于携带该会话 ID 的请求,服务器必须返回 HTTP 404 "未找到"(Not Found)响应。

若客户端在发送携带Mcp-Session-Id的请求后收到 HTTP 404 响应,则必须通过发送不带会话 ID 的新 "初始化请求"(InitializeRequest)来启动新会话。

当客户端不再需要某个特定会话时(例如,用户退出客户端应用),应向 MCP 端点发送 HTTP DELETE 请求,并在请求头Mcp-Session-Id中携带该会话 ID,以显式终止会话。

服务器可对该 DELETE 请求返回 HTTP 405 "方法不允许"(Method Not Allowed)响应,表明服务器不支持客户端主动终止会话。

总结一下,就是通过sessionId来作为一组对话上下文数据的唯一标识,后续只要携带这个标识,服务器就可以根据该标识查找到对应的上下文数据进行进一步的对话处理

2.1.2 总体逻辑解释

mcpClient类中需要包含一个Client实例、mcp服务端url、一个transport(不知道应该翻译成啥,觉得应该叫传输器)、工具列表(类型需要参考自己所需大模型的工具类型,这里使用Qwen系列)、sessionId

接着需要实现一个connectToServer函数用于连接mcp服务器,首先需要new 一个 transport,参数传入mcp服务器url。接着client实例使用connect方法连接这个transport,连接成功后即可通过transport实例获取sessionId。拿到sessionId后即可创建重连的相关函数,本例就不再创建

接着client通过listTools方法获取到工具列表,然后遍历组装成自己所需的格式存入tools属性中,以便后续大模型进行调用。本例使用Qwen大模型,其官方示例代码地址如下:Qwen3工具调用

最后封装一个调用工具的方法callTool,即让client调用callTool方法传入调用工具名和工具参数即可调用工具返回结果

2.1.3 完整代码

typescript 复制代码
// With Session Management
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { ChatCompletionTool } from 'openai/resources/chat/completions'
export class MCPClient {
    private client: Client
    private mcpServerURL: URL
    //   private anthropic: Anthropic;
    private transport: StreamableHTTPClientTransport | null = null
    private tools: ChatCompletionTool[] = []
    private sessionId?: string

    constructor(mcpServerURL: string) {
        // this.anthropic = new Anthropic({
        //   apiKey: ANTHROPIC_API_KEY,
        // });
        this.client = new Client({ name: "mcp-client-cli", version: "1.0.0" })
        this.mcpServerURL = new URL(mcpServerURL)
    }
    // methods will go here
    connectToServer = async () => {
        try {
            this.transport = new StreamableHTTPClientTransport(this.mcpServerURL)
            await this.client.connect(this.transport)

            // 连接成功后,从 transport 中获取服务端生成的 sessionId
            this.sessionId = this.transport.sessionId;
            console.log('服务端生成的 sessionId:', this.sessionId);
            console.log('Connected using Streamable HTTP transport');
            //获取的工具列表
            const toolsResult = await this.client.listTools();
            //遍历列表,组装成大模型需要的工具格式
            this.tools = toolsResult.tools.map((tool) => ({
                "type": "function",
                "function": {
                    "name": tool.name,
                    "description": tool.description,
                    "parameters": tool.inputSchema,
                },
            }));
            console.log(
                "Connected to server with tools:",
                JSON.stringify(this.tools)
            );
        } catch (error) {
            console.error("连接失败:", error);
        }

    }
    getTools = () => {
        return this.tools
    }
    callTool = async (toolName: string, toolCallArgsStr: string) => {
        if (!this.sessionId) {
            throw new Error("未连接到服务端,请先调用 connectToServer()");
        }
        const result = await this.client.callTool({
            name: toolName,
            arguments: JSON.parse(toolCallArgsStr),
        })
        return result
    }
    // 获取当前 sessionId 的方法
    getSessionId = (): string | undefined => {
        return this.sessionId;
    };
}

2.2 会话模式对应的服务端

服务端核心代码是生成transport时,设置sessionIdGenerator,通过uuid生成一个sessionId,即

typescript 复制代码
transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: () => crypto.randomUUID(),
            onsessioninitialized: sessionId => {
                // Store the transport by session ID
                transports[sessionId] = transport;
            }

2.2.1 完整代码

此服务端代码参考了官方示例代码,很多时候直接拉下来,然后改成自己需要的逻辑即可

typescript 复制代码
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';
import { crawlWebFn } from './crawlWeb.js';
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';

// Set up Express and HTTP transport
const app = express();
app.use(express.json());


// Map to store transports by session ID
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};

// Handle POST requests for client-to-server communication
app.post('/mcp', async (req, res) => {
    // Check for existing session ID
    const sessionId = req.headers['mcp-session-id'] as string | undefined;
    console.log('sessionId', sessionId)
    let transport: StreamableHTTPServerTransport;

    if (sessionId && transports[sessionId]) {
        // Reuse existing transport
        transport = transports[sessionId];
    } else if (!sessionId && isInitializeRequest(req.body)) {
        // New initialization request
        transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: () => crypto.randomUUID(),
            onsessioninitialized: sessionId => {
                // Store the transport by session ID
                transports[sessionId] = transport;
            }
            // DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server
            // locally, make sure to set:
            // enableDnsRebindingProtection: true,
            // allowedHosts: ['127.0.0.1'],
        });

        // Clean up transport when closed
        transport.onclose = () => {
            if (transport.sessionId) {
                delete transports[transport.sessionId];
            }
        };
        const server = new McpServer({
            name: 'example-server',
            version: '1.0.0'
        });

        // ... set up server resources, tools, and prompts ...
        // Register weather tools
        server.tool(
            "crawlWeb",
            "爬取获取网页内容",
            {
                url: z.string().url().describe('需要被爬取的网页链接')
            },
            async ({ url }) => {
                try {
                    const result = await crawlWebFn(url) as string
                    return {
                        content: [
                            {
                                type: "text",
                                text: result,
                            },
                        ],
                    };
                } catch (error) {
                    return {
                        content: [
                            {
                                type: "text",
                                text: '爬取网页失败',
                            },
                        ],
                    };
                }
            },
        );
        // Connect to the MCP server
        await server.connect(transport);
    } else {
        // Invalid request
        res.status(400).json({
            jsonrpc: '2.0',
            error: {
                code: -32000,
                message: 'Bad Request: No valid session ID provided'
            },
            id: null
        });
        return;
    }

    // Handle the request
    await transport.handleRequest(req, res, req.body);
});

const port = parseInt(process.env.PORT || '4002');
app.listen(port, () => {
    console.log(`MCP Server running on http://localhost:${port}/mcp`);
}).on('error', error => {
    console.error('Server error:', error);
    process.exit(1);
});

2.3 无会话模式对应的服务端

无会话模式就更简单了,直接设sessionIdGenerator为undefined即可

typescript 复制代码
const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: undefined
    });

2.3.1 完整代码

typescript 复制代码
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { z } from 'zod';
import { crawlWebFn } from './crawlWeb.js';


// Set up Express and HTTP transport
const app = express();
app.use(express.json());

app.post('/mcp', async (req, res) => {
    console.log(req.headers)
    // Create a new transport for each request to prevent request ID collisions
    const transport = new StreamableHTTPServerTransport({
        sessionIdGenerator: undefined
    });

    // Create an MCP server
    const server = new McpServer({
        name: 'mcp-server-crawl',
        version: '1.0.0'
    });

    // Register weather tools
    server.tool(
        "crawlWeb",
        "爬取获取网页内容",
        {
            url: z.string().url().describe('需要被爬取的网页链接')
        },
        async ({ url }) => {
            try {
                const result = await crawlWebFn(url) as string
                return {
                    content: [
                        {
                            type: "text",
                            text: result,
                        },
                    ],
                };
            } catch (error) {
                return {
                    content: [
                        {
                            type: "text",
                            text: '爬取网页失败',
                        },
                    ],
                };
            }
        },
    );

    res.on('close', () => {
        transport.close();
    });

    await server.connect(transport);
    await transport.handleRequest(req, res, req.body);
    req.on('error', () => {
        console.log('error')
    });
});

const port = parseInt(process.env.PORT || '4002');
app.listen(port, () => {
    console.log(`MCP Server running on http://localhost:${port}/mcp`);
}).on('error', error => {
    console.error('Server error:', error);
    process.exit(1);
});`在这里插入代码片`

3 index.ts

MCP遵循CS架构,其中Client属于BS架构中的Server端,所以Client实际上是与浏览器交互的服务器,所以就需要写一些后端接口,完整代码如下。

核心逻辑:

调用大模型返回的结果

  • 如果有思考的内容,拼接思考的内容, 流式输出内容
  • 如果出现工具名称,收集工具名称
  • 如果出现工具参数,收集工具参数
  • 如果出现工具结束标志,就拿之前收集的工具名称和工具参数进行工具调用
  • 如果出现大模型回复内容,拼接回复内容,流式输出内容

此逻辑参考Qwen深度思考示例代码

typescript 复制代码
import express from 'express'
import cors from 'cors'
import { MCPClient } from './mcpClient.js'
import { ChatCompletionMessageParam } from 'openai/resources/chat/completions'
import { OpenAI } from "openai";

interface ExtendedDelta extends OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta {
    reasoning_content?: string
}

interface Qwen3 extends OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming {
    enable_thinking?: boolean
}


const mcpClient: MCPClient = new MCPClient('http://127.0.0.1:4002/mcp')

const openai = new OpenAI(
    {
        // 若没有配置环境变量,请用百炼API Key将下行替换为:apiKey: "sk-xxx",
        apiKey: 'your api key',
        baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1"
    }
);

const app = express()
app.use(cors())
app.use(express.urlencoded({ extended: true }))
app.use(express.json())

const prompt = `这是一个可以爬取阅读网页文字内容的工具,如果用户提供了一段完整的网页链接并且需求是获取阅读网页,请调用该工具,否则不调用该工具`
const message: ChatCompletionMessageParam[] = [{
    role: 'system',
    content: prompt
}]



let reasoningContent = ''
let answerContent = ''
let isAnswering = false

app.post('/chat', async (req, res) => {
    console.log(req.body)
    const { userContent } = req.body
    message.push({ role: 'user', content: userContent })
    try {
        const stream = await openai.chat.completions.create({
            // 您可以按需更换为其它 Qwen3 模型、QwQ模型或DeepSeek-R1 模型
            model: 'qwen-plus',
            messages: message,
            tools: mcpClient.getTools(),
            stream: true,
            enable_thinking: true
        } as Qwen3)

        //工具名称
        let toolName = ''
        //工具调用参数
        let toolCallArgsStr = ''
        console.log('\n' + '='.repeat(20) + '思考过程' + '='.repeat(20) + '\n');
        for await (const chunk of stream) {
            if (!chunk.choices?.length) {
                console.log('\nUsage:');
                console.log(chunk.usage);
                continue;
            }
            // console.log(JSON.stringify(chunk))

            const delta: ExtendedDelta = chunk.choices[0].delta;

            // 只收集思考内容
            if (delta.reasoning_content !== undefined && delta.reasoning_content !== null) {
                if (!isAnswering) {
                    process.stdout.write(delta.reasoning_content);
                }
                reasoningContent += delta.reasoning_content;
            }
            //大模型开始思考调用哪个工具,并输出工具和生成参数
            //收集并拼接工具名称
            if (delta.tool_calls && delta.tool_calls[0].function?.arguments) {
                toolCallArgsStr += delta.tool_calls[0].function.arguments
            }
            //收集并拼接工具参数
            if (delta.tool_calls && delta.tool_calls[0].function?.name) {
                toolName += delta.tool_calls[0].function.name
            }
            //判断工具输出结束标志
            if (chunk.choices[0].finish_reason === 'tool_calls') {
                //此处可以调用大模型调用工具
                const result = await mcpClient.callTool(toolName, toolCallArgsStr);
                console.log('mcp爬取网页内容', JSON.stringify(result))
                //再次发送给大模型让大模型输出
                message.push({ role: 'user', content: result.content as string })
                const toolStream = await openai.chat.completions.create({
                    // 您可以按需更换为其它 Qwen3 模型、QwQ模型或DeepSeek-R1 模型
                    model: 'qwen-plus',
                    messages: message,
                    stream: true,
                    enable_thinking: false
                } as Qwen3)
                //流式输出
                for await (const chunk of toolStream) {
                    if (!chunk.choices?.length) {
                        console.log('\nUsage:');
                        console.log(chunk.usage);
                        continue;
                    }
                    const delta: ExtendedDelta = chunk.choices[0].delta;
                    // 收到content,开始进行回复
                    if (delta.content !== undefined && delta.content) {
                        if (!isAnswering) {
                            console.log('\n' + '='.repeat(20) + '调用工具后的回复' + '='.repeat(20) + '\n');
                            isAnswering = true;
                        }
                        process.stdout.write(delta.content);
                        answerContent += delta.content;
                    }
                }

            }
            // 收到content,开始进行回复
            if (delta.content !== undefined && delta.content) {
                if (!isAnswering) {
                    console.log('\n' + '='.repeat(20) + '完整回复' + '='.repeat(20) + '\n');
                    isAnswering = true;
                }
                process.stdout.write(delta.content);
                answerContent += delta.content;
            }
        }
    } catch (error) {
        console.error('Error:', error);
    }
})

app.listen(5000, async () => {
    console.log('mcp服务启动成功, 端口是5000')
    try {
        await mcpClient.connectToServer()
    } catch (error) {
        console.error('mcp服务器连接失败', error)
    }
})

4 测试效果

4.1 打包并启动项目

powershell 复制代码
//打包服务器ts为js可运行代码
npm run build

//启动 mcp 服务器
pnpm run start1

//服务器启动
MCP Server running on http://localhost:4002/mcp
powershell 复制代码
//启动客户端
npm run dev

客户端启动成功并打印出工具列表

此时服务端生成了sessionId,并存储在服务器中,接着测试一下,重启客户端并携带sessionId

4.2 测试携带与不携带sessionId连接

首先测试携带之前在服务端存储的sessionId进行连接

typescript 复制代码
this.transport = new StreamableHTTPClientTransport(this.mcpServerURL, {sessionId: 'f94e4537-d016-405a-ba21-fc3811b10877'})

重启并连接成功

接着尝试输入错误的sessionId尝试连接

typescript 复制代码
this.transport = new StreamableHTTPClientTransport(this.mcpServerURL, {sessionId: 'error sessionId'})

连接失败

打印内容

powershell 复制代码
Error: Error POSTing to endpoint (HTTP 400): {"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: No valid session ID provided"},"id":null}

所以session应用成功,接着测试对话和工具调用

4.3 测试简单对话

4.3.1 输入怎么炒鸡蛋

在postman中调用post请求,携带内容问ai怎么炒鸡蛋

4.3.2 大模型输出

大模型思考得出结论这个问题不需要调用工具并生成了回复

4.4 测试工具调用

4.4.1 输入怎么炒鸡蛋

在postman中调用post请求,内容中包含网页链接,并提问ai

4.4.2 大模型输出

大模型根据代码中写的提示词进行了思考,得出要调用工具的结论,于是,调用crawlWeb函数爬取了链接对应的文章内容

得到爬取的网页内容后将该文本内容继续回复给大模型,大模型根据上下文进行了进一步回复,即对这篇文章进行了总结,至此测试成功,符合预期。

5 小小总结

感觉目前mcp相关的文章以及bug对应的文章很少,自己遇见的问题很难从论坛中得到解决方法,通过提问大模型也经常容易出现幻觉,给出一些不存在的api,不过好在最终简单走通了整个流程,学无止尽。

相关推荐
蒜香拿铁4 小时前
Angular【基础语法】
前端·javascript·angular.js
元直数字电路验证5 小时前
ASP.NET Core Web APP(MVC)开发中无法全局配置 NuGet 包,该怎么解?
前端·javascript·ui·docker·asp.net·.net
CS创新实验室5 小时前
【译文】DeepSeek-OCR:上下文光学压缩
ai·大模型·llm·ocr·deepseek-ocr
我有一棵树6 小时前
Vue 项目中全局样式的正确写法:不要把字体和主题写在 #app 上
前端·javascript·vue.js
龙仔CLL7 小时前
微前端乾坤vue3项目使用tinymce,通过npm,yarn,pnpm包安装成功,但是引用报错无法使用
javascript·arcgis·npm
Luna-player7 小时前
npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本,解决方法
前端·npm·node.js
测试开发Kevin7 小时前
大语言模型技术Agentic Context Engineering (ACE) 的到来会取代微调吗
人工智能·ai·语言模型·自然语言处理
Mountain088 小时前
解决 Node.js 启动报错:digital envelope routines 错误全记录
javascript·npm·node.js
水中加点糖8 小时前
使用LangChain+LangGraph自定义AI工作流,实现音视频字幕生成工具
人工智能·ai·langchain·工作流·langgraph