掘金 人工智能 04月02日 18:57
前端搭建 MCP Client(Web版)+ Server + Agent 实践
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文分享了使用CopilotKit和LangChain构建Web版MCP Client和Server的经验。文章介绍了MCP协议的概念,并详细阐述了技术栈、核心依赖库以及Client和Server的实现细节。重点在于JS版本的Agent构建,以及在实际开发中遇到的问题和解决方案,例如环境变量配置和跨平台兼容性。文章还简要介绍了前端应用部分,包括CopilotKit UI的接入和整体架构。

💡MCP(Model Context Protocol)是一种开放协议,旨在实现大语言模型(LLMs)与外部数据源及工具的无缝集成,类似于USB接口,提供“即插即用”的扩展能力。

⚙️文章基于CopilotKit开源的MCP Client进行二次改造,重点在于构建基于JS的Agent,解决了Python Agent在Windows环境下的报错问题。Agent部分使用LangGraph创建workflow,通过ChatOpenAI模型和MCP Client连接MCP Server,获取Server的tools,最终实现与模型的交互。

🛠️在开发过程中,作者遇到了引入`@modelcontextprotocol/sdk`报错的问题,主要是由于其依赖的`pkce-challenge`不支持CJS规范。解决办法是在`package.json`中添加`"type": "module"`,声明项目使用ESM规范。

🌐前端应用部分采用了CopilotKit + Next.js的架构,通过设置运行时端点和接入CopilotKit UI,实现了与后端Agent的交互。其中,`/app/api/copilotkit/route.ts`设置了agent远程端点,`/app/layout.tsx`则使用CopilotKit包裹页面,配置runtimeUrl和agent。

先上个效果图,上图是在 web 版 Client 中使用 todoist-mcp-server 帮我新建了一条 todolist

本文主要介绍整体的思路和实现以及踩坑记录。

前言

MCP(Model Context Protocol)是一种开放协议,旨在通过标准化接口实现大语言模型(LLMs)与外部数据源及工具的无缝集成。MCP由 Anthropic 公司在2024年底推出,其设计理念类似于USB接口,为AI模型提供了一个“即插即用”的扩展能力,使其能够轻松连接至不同的工具和数据源‌。想深入了解可查看 官方文档,这里只做实战经验分享。

概念介绍

    MCP Hosts(MCP 应用):如Claude Desktop、IDE、AI应用等,希望通过MCP访问数据或工具。MCP Clients(MCP 客户端):与一对一与服务端进行连接,相当于我们应用中实现数据库交互需要实现的一个客户端。MCP Servers(MCP 服务端):基于MCP协议实现特定功能的程序。Local Data Sources:本地数据源,公MCP服务器进行本地访问。Remote Services:远端服务,供MCP服务器访问远端访问,例如api的方式。

本文主要搭建 Web 版本的 MCP ClientMCP Server

技术栈

系统要求:Node.js >= 18(本地用了v20)

核心依赖库:CopilotKitLangChain及其生态。

Client

页面大概这样,包括:左侧管理MCP Server、右侧聊天机器人

技术方案

声明:此 Client 是基于CopilotKit 开源的 MCP Client open-mcp-client 二次改造

该代码库主要分为两个部分:

    /agent – 连接到 MCP Server并调用其工具的LangGraph代理(Python)。/app – 使用 CopilotKit 进行UI和状态同步的前端应用程序(Next.js)。

由于 PythonagentWindows 环境下运行时报错:

本人Python编码能力有限,基于此改造成了基于 JSagent/agent-js部分),后续均以agent-js为例;想用 Python 的也可按后续的改动点对 /agent 进行修改。

一、agent部分

文件结构

核心代码

agent.js - 基于 langgraph 创建 workflow,其中主要节点为 chat_node,该节点功能点:

import { ChatOpenAI } from "@langchain/openai";...    const model = new ChatOpenAI(    {      temperature: 0,      model: "gpt-4o",    },                  );...

注意:本地联调需访问 openai 时,如果是使用的代理工具,还是需要在代码里指定代理地址(HttpsProxyAgent)

    state 获取 MCP Server Configs,创建 MCP Client 连接到 MCP Server,连通后获取 Servertools。(@langchain/mcp-adapters)
const mcpConfig: any = state.mcp_config || {};    let newMcpConfig: any = {};  Object.keys(mcpConfig).forEach((key) => {    newMcpConfig[key] = { ...mcpConfig[key] };    if (newMcpConfig[key].env) {      newMcpConfig[key].env = { ...process.env, ...newMcpConfig[key].env };    }  });  console.log("****mcpConfig****", mcpConfig);    const client = new MultiServerMCPClient(newMcpConfig);                      await client.initializeConnections();  const tools = client.getTools();
    基于 modeltools 创建代理,并调用模型发送状态中的消息
  const agent = createReactAgent({    llm: model,    tools,  });    const response = await agent.invoke({ messages: state.messages });

完整代码

agent.js

import { RunnableConfig } from "@langchain/core/runnables";import {  MemorySaver,  START,  StateGraph,  Command,  END,} from "@langchain/langgraph";import { createReactAgent } from "@langchain/langgraph/prebuilt";import { Connection, MultiServerMCPClient } from "@langchain/mcp-adapters";import { AgentState, AgentStateAnnotation } from "./state";import { getModel } from "./model";const isWindows = process.platform === "win32";const DEFAULT_MCP_CONFIG: Record<string, Connection> = {  supos: {    command: isWindows ? "npx.cmd" : "npx",    args: [      "-y",      "mcp-server-supos",    ],    env: {      SUPOS_API_URL: process.env.SUPOS_API_URL || "",      SUPOS_API_KEY: process.env.SUPOS_API_KEY || "",      SUPOS_MQTT_URL: process.env.SUPOS_MQTT_URL || "",    },    transport: "stdio",  },};async function chat_node(state: AgentState, config: RunnableConfig) {    const model = getModel(state);  const mcpConfig: any = { ...DEFAULT_MCP_CONFIG, ...(state.mcp_config || {}) };    let newMcpConfig: any = {};  Object.keys(mcpConfig).forEach((key) => {    newMcpConfig[key] = { ...mcpConfig[key] };    if (newMcpConfig[key].env) {      newMcpConfig[key].env = { ...process.env, ...newMcpConfig[key].env };    }  });  console.log("****mcpConfig****", mcpConfig);    const client = new MultiServerMCPClient(newMcpConfig);                    await client.initializeConnections();  const tools = client.getTools();    const agent = createReactAgent({    llm: model,    tools,  });    const response = await agent.invoke({ messages: state.messages });    return [    new Command({      goto: END,      update: { messages: response.messages },    }),  ];}const workflow = new StateGraph(AgentStateAnnotation)  .addNode("chat_node", chat_node)  .addEdge(START, "chat_node");const memory = new MemorySaver();export const graph = workflow.compile({  checkpointer: memory,});

model.js

import { BaseChatModel } from "@langchain/core/language_models/chat_models";import { AgentState } from "./state";import { ChatOpenAI } from "@langchain/openai";import { ChatAnthropic } from "@langchain/anthropic";import { ChatMistralAI } from "@langchain/mistralai";function getModel(state: AgentState): BaseChatModel {    const stateModel = state.model;  const stateModelSdk = state.modelSdk;    const stateApiKey = atob(state.apiKey || "");  const model = process.env.MODEL || stateModel;  console.log(    `Using stateModelSdk: ${stateModelSdk}, stateApiKey: ${stateApiKey}, stateModel: ${stateModel}`  );  if (stateModelSdk === "openai") {    return new ChatOpenAI({      temperature: 0,      model: model || "gpt-4o",      apiKey: stateApiKey || undefined,    }                      );  }  if (stateModelSdk === "anthropic") {    return new ChatAnthropic({      temperature: 0,      modelName: model || "claude-3-7-sonnet-latest",      apiKey: stateApiKey || undefined,    });  }  if (stateModelSdk === "mistralai") {    return new ChatMistralAI({      temperature: 0,      modelName: model || "codestral-latest",      apiKey: stateApiKey || undefined,    });  }  throw new Error("Invalid model specified");}export { getModel };

state.js

import { Annotation } from "@langchain/langgraph";import { CopilotKitStateAnnotation } from "@copilotkit/sdk-js/langgraph";import { Connection } from "@langchain/mcp-adapters";export const AgentStateAnnotation = Annotation.Root({  model: Annotation<string>,  modelSdk: Annotation<string>,  apiKey: Annotation<string>,  mcp_config: Annotation<Connection>,  ...CopilotKitStateAnnotation.spec,});export type AgentState = typeof AgentStateAnnotation.State;

构建和运行

    定义 langgraph.json 配置文件,定义 agent 相关配置,比如 agent 名称:sample_agent
{  "node_version": "20",  "dockerfile_lines": [],  "dependencies": ["."],  "graphs": {    "sample_agent": "./src/agent.ts:graph"   },  "env": ".env" }

在本地运行时,在根路径 /agent-js 添加 .env 文件

LANGSMITH_API_KEY=lsv2_...OPENAI_API_KEY=sk-...

2. 借助命令行工具@langchain/langgraph-cli进行构建和运行,在 package.json 中定义脚本:

"scripts": {    "start": "npx @langchain/langgraph-cli dev --host localhost --port 8123",    "dev": "npx @langchain/langgraph-cli dev --host localhost --port 8123 --no-browser"  },

加上--no-browser不会自动打开本地调试的studio页面

运行后可以在 Studio smith.langchain.com/studio?base… 预览联调等

注意点(踩坑记录)

1. 引入 modelcontextprotocol/typescript-sdk 报错:

@modelcontextprotocol/sdk fails in CommonJS projects due to incompatible ESM-only dependency (pkce-challenge)

主要是modelcontextprotocol/typescript-sdk的cjs包里面引用的pkce-challenge不支持cjs官方的 issues 也有提出些解决方案,但目前为止官方还未发布解决了该问题的版本

解决:package.json 添加"type": "module" 字段,声明项目使用 ES Modules (ESM)  规范

2. 配置 MCP Server 环境变量 env 问题

例如:Node.js 的 child_process.spawn() 方法无法找到例如 npx 等可执行文件。
环境变量 PATH 缺失,系统未正确识别 npx 的安装路径。

可能的原因:

1)MCP Server 配置了 env 参数后,导致传入的 env 覆盖了默认从父进程获取的环境变量

解决:对配置了 envServer,将当前的环境变量合并传入

const mcpConfig: any = state.mcp_config || {};    let newMcpConfig: any = {};  Object.keys(mcpConfig).forEach((key) => {    newMcpConfig[key] = { ...mcpConfig[key] };    if (newMcpConfig[key].env) {      newMcpConfig[key].env = { ...process.env, ...newMcpConfig[key].env };    }  });
2) 跨平台路径问题:比如在 Windows 中直接调用 npx 需使用 npx.cmd
const isWindows = process.platform === "win32";const DEFAULT_MCP_CONFIG: Record<string, Connection> = {  supos: {    command: isWindows ? "npx.cmd" : "npx",    args: [      "-y",      "mcp-server-supos",    ],    env: {      SUPOS_API_URL: process.env.SUPOS_API_URL || "",      SUPOS_API_KEY: process.env.SUPOS_API_KEY || "",      SUPOS_MQTT_URL: process.env.SUPOS_MQTT_URL || "",    },    transport: "stdio",  },};

二、前端应用部分

前端应用部分改动主要是页面上的一些功能添加等,例如支持选模型,支持配置 env 参数等,页面功能相关的内容就略过,可以直接看 open-mcp-client,这里简单介绍下整体的一个架构。

架构方案

主要是 CopilotKit + Next.js,先看下 CopilotKit 官方的一个架构图:

根据本文实际用到的简化下(本文采用的 CoAgents模式

核心代码(以Next.js为例)

核心依赖 @copilotkit/react-ui @copilotkit/react-core @copilotkit/runtime

1. 设置运行时端点

/app/api/copilotkit/route.ts:设置 agent 远程端点

import {    CopilotRuntime,    ExperimentalEmptyAdapter,    copilotRuntimeNextJSAppRouterEndpoint,    langGraphPlatformEndpoint} from "@copilotkit/runtime";;import { NextRequest } from "next/server";const serviceAdapter = new ExperimentalEmptyAdapter();const runtime = new CopilotRuntime({    remoteEndpoints: [        langGraphPlatformEndpoint({                        deploymentUrl: `${process.env.AGENT_DEPLOYMENT_URL || 'http://localhost:8123'}`,             langsmithApiKey: process.env.LANGSMITH_API_KEY,            agents: [                {                    name: 'sample_agent',                     description: 'A helpful LLM agent.',                }            ]        }),    ],});export const POST = async (req: NextRequest) => {    const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({        runtime,        serviceAdapter,        endpoint: "/api/copilotkit",    });    return handleRequest(req);};
2. 页面接入 CopilotKit UI

/app/layout.tsx:页面最外层用 CopilotKit 包裹,配置 runtimeUrlagent

import type { Metadata } from "next";import { Geist, Geist_Mono } from "next/font/google";import "./globals.css";import "@copilotkit/react-ui/styles.css";import { CopilotKit } from "@copilotkit/react-core";const geistSans = Geist({  variable: "--font-geist-sans",  subsets: ["latin"],});const geistMono = Geist_Mono({  variable: "--font-geist-mono",  subsets: ["latin"],});export const metadata: Metadata = {  title: "Open MCP Client",  description: "An open source MCP client built with CopilotKit 🪁",};export default function RootLayout({  children,}: Readonly<{  children: React.ReactNode;}>) {  return (    <html lang="en">      <body        className={`${geistSans.variable} ${geistMono.variable} antialiased w-screen h-screen`}      >        <CopilotKit          runtimeUrl="/api/copilotkit"          agent="sample_agent"          showDevConsole={false}        >          {children}        </CopilotKit>      </body>    </html>  );}

/app/page.tsx:选择需要的聊天组件,例如 CopilotPopup

"use client";import { CopilotPopup } from "@copilotkit/react-ui";export function Home() {  return (    <>      <YourMainContent />      <CopilotChat          className="h-full flex flex-col"          instructions={            "You are assisting the user as best as you can. Answer in the best way possible given the data you have."          }          labels={{            title: "MCP Assistant",            initial: "Need any help?",          }}        />    </>  );}

构建和运行

这里就参照 Next.js 官方即可

package.json

  "scripts": {    "dev-frontend": "pnpm i && next dev --turbopack",    "dev-agent-js": "cd agent-js && pnpm i && npx @langchain/langgraph-cli dev --host 0.0.0.0 --port 8123 --no-browser",    "dev-agent-py": "cd agent && poetry install && poetry run langgraph dev --host 0.0.0.0 --port 8123 --no-browser",    "dev": "pnpx concurrently \"pnpm dev-frontend\" \"pnpm dev-agent-js\" --names ui,agent --prefix-colors blue,green",    "build": "next build",    "start": "next start",    "lint": "next lint"  },

Server

建议直接参考 MCP Server Typescript SDK 示例开发,官网文档的用法更新没那么及时,容易走弯路。

mcp-server-supos 是一个可用的 MCP Server,也发布了对应的 npm 包

这里截取核心代码片段,想了解更多可点击查看源码和使用文档等。

核心代码

    提供tool-调用API查询信息实时订阅MQTT topic数据进行缓存,用于提供 tool 查询分析最新数据示例 server.resource

index.ts

#!/usr/bin/env nodeimport { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";import fetch from "node-fetch";import { z } from "zod";import fs, { readFileSync } from "fs";import _ from "lodash";import mqtt from "mqtt";import { pathToFileURL } from "url";import { createFilePath } from "./utils.js";let SUPOS_API_URL =  process.env.SUPOS_API_URL;let SUPOS_API_KEY =  process.env.SUPOS_API_KEY;let SUPOS_MQTT_URL =  process.env.SUPOS_MQTT_URL;if (!SUPOS_API_URL) {  console.error("SUPOS_API_URL environment variable is not set");  process.exit(1);}if (!SUPOS_API_KEY) {  console.error("SUPOS_API_KEY environment variable is not set");  process.exit(1);}const filePath = createFilePath();const fileUri = pathToFileURL(filePath).href;async function getModelTopicDetail(topic: string): Promise<any> {  const url = `${SUPOS_API_URL}/open-api/supos/uns/model?topic=${encodeURIComponent(    topic  )}`;  const response = await fetch(url, {    headers: {      apiKey: `${SUPOS_API_KEY}`,    },  });  if (!response.ok) {    throw new Error(`SupOS API error: ${response.statusText}`);  }  return await response.json();}function getAllTopicRealtimeData() {    const cache = new Map();  let timer: any = null;  const options = {    clean: true,    connectTimeout: 4000,    clientId: "emqx_topic_all",    rejectUnauthorized: false,    reconnectPeriod: 0,   };  const connectUrl = SUPOS_MQTT_URL;  if (!connectUrl) {    return;  }  const client = mqtt.connect(connectUrl, options);  client.on("connect", function () {    client.subscribe("#", function (err) {          });  });  client.on("message", function (topic, message) {    cache.set(topic, message.toString());  });  client.on("error", function (error) {      });  client.on("close", function () {    if (timer) {      clearInterval(timer);    }  });    timer = setInterval(() => {    const cacheJson = JSON.stringify(      Object.fromEntries(Array.from(cache)),      null,      2    );        fs.writeFile(      filePath,      cacheJson,      {        encoding: "utf-8",      },      (error) => {        if (error) {          fs.writeFile(            filePath,            JSON.stringify({ msg: "写入数据失败" }, null, 2),            { encoding: "utf-8" },            () => {}          );        }      }    );  }, 5000);}function createMcpServer() {  const server = new McpServer(    {      name: "mcp-server-supos",      version: "0.0.1",    },    {      capabilities: {        tools: {},      },    }  );    server.resource("all-topic-realtime-data", fileUri, async (uri) => ({    contents: [      {        uri: uri.href,        text: readFileSync(filePath, { encoding: "utf-8" }),      },    ],  }));  server.tool(    "get-model-topic-detail",    { topic: z.string() },    async (args: any) => {      const detail = await getModelTopicDetail(args.topic);      return {        content: [{ type: "text", text: `${JSON.stringify(detail)}` }],      };    }  );  server.tool("get-all-topic-realtime-data", {}, async () => {    return {      content: [        {          type: "text",          text: readFileSync(filePath, { encoding: "utf-8" }),        },      ],    };  });  async function runServer() {    const transport = new StdioServerTransport();    const serverConnect = await server.connect(transport);    console.error("SupOS MCP Server running on stdio");    return serverConnect;  }  runServer().catch((error) => {    console.error("Fatal error in main():", error);    process.exit(1);  });}async function main() {  try {    createMcpServer();    getAllTopicRealtimeData();  } catch (error) {    console.error("Error in main():", error);    process.exit(1);  }}main();

utils.ts

import fs from "fs";import path from "path";export function createFilePath(  filedir: string = ".cache",  filename: string = "all_topic_realdata.json") {    const rootPath = process.cwd();    const filePath = path.resolve(rootPath, filedir, filename);  const dirPath = path.dirname(filePath);    if (!fs.existsSync(dirPath)) {    fs.mkdirSync(dirPath, { recursive: true });  }  return filePath;}export function readFileSync(filePath: string, options: any) {  try {    return fs.readFileSync(filePath, options);  } catch (err) {    return `读取文件时出错: ${err}`;  }}

如何使用

Client:目前支持MCP协议的客户端已有很多,比如桌面端应用 Claude for Desktop,或者IDE的一些插件等(VSCode 的 Cline 插件),想了解已支持的客户端可访问 Model Context Protocol Client

Server:除了官方例子Model Context Protocol Client 外,已有很多网站整合了 MCP Servers,例如 mcp.so, Glama 等。

下面列举几个介绍下:

1)配置

2)使用

2. 配合 Claude 使用

具体可参考:mcp-server-supos README.md,服务换成自己需要的即可

3. 使用 VSCode 的 Cline 插件

由于使用 npx 找不到路径,这里以 node 执行本地文件为例

1)配置

2)使用

结语

以上便是近期使用 MCP 的一点小经验~

整理完后看了下,如果只是单纯想集成些 MCP Server,其实可以不用 agent 形式,直接使用 copilotkit 的标准模式,在本地服务调用 langchainjs-mcp-adapters 和 LLM 即可,例如:

import {  CopilotRuntime,  LangChainAdapter,  copilotRuntimeNextJSAppRouterEndpoint,} from '@copilotkit/runtime';import { ChatOpenAI } from "@langchain/openai";import { NextRequest } from 'next/server'; ... const model = new ChatOpenAI({ model: "gpt-4o", apiKey: process.env.OPENAI_API_KEY });const serviceAdapter = new LangChainAdapter({    chainFn: async ({ messages, tools }) => {    return model.bindTools(tools).stream(messages);          }});const runtime = new CopilotRuntime(); export const POST = async (req: NextRequest) => {  const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({    runtime,    serviceAdapter,    endpoint: '/api/copilotkit',  });   return handleRequest(req);};

但这样可能少了些上下文状态等,具体可以下来都试试~

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

MCP协议 CopilotKit LangChain Web开发 大语言模型
相关文章