🎉 使用 Next.js 构建你自己的聊天 MCP 客户端⚡

2025-06-08

🎉 使用 Next.js 构建你自己的聊天 MCP 客户端⚡

厌倦了用 Next.js 构建千篇一律的 AI 聊天机器人应用程序?😪

我敢打赌你是的,但是如何构建一个由 MCP 驱动的聊天应用程序,可以同时连接远程和本地 MCP 服务器呢?

震惊 GIF

如果这听起来很有趣,请继续关注博客,我们将共同构建一个超酷的 MCP 驱动的 AI 聊天机器人,它支持多工具调用,有点类似于ClaudeWindsurf

让我们立即开始吧!


涵盖哪些内容?

在这个简单易懂的教程中,您将学习如何使用 Next.js 构建自己的聊天 AI 驱动的 MCP 客户端,该客户端可以连接到远程和本地托管的 MCP 服务器。

你将学到:

  • 什么是 MCP?
  • 如何构建 Next.js 聊天 MCP 客户端应用程序
  • 如何将 MCP 客户端与 Composio 托管的 MCP 服务器连接
  • 如何连接 MCP 客户端和本地 MCP 服务器

MCP 概述

💁 在我们开始使用 MCP 构建事物之前,让我先大致介绍一下 MCP 的含义。

MCP 代表模型上下文协议,可以将其视为 AI 模型和外部工具之间的桥梁,可以为其提供数据和对其采取行动的能力。

模型上下文协议工作

这对某些人来说可能是一个难题,所以让我与你们分享它所解决的问题,以便你们能够更好地想象。

生成式人工智能模型在现实场景中用处不大,对吧?它们只能根据训练数据提供信息。

这些信息并非实时,而且大多已过时。此外,他们无法自行对数据采取任何行动。MCP 解决了这两个问题。这就是 MCP 的核心理念。

添加 MCP 支持后,您可以对 AI 模型执行以下一些操作:

  • ✅ 通过 Gmail/Outlook 发送电子邮件。
  • ✅ 在 Slack 中发送消息。
  • ✅ 创建问题、PR、提交等。
  • ✅ 在 Google 日历中安排或创建会议。

可能性无穷无尽。(所有这些都是通过向人工智能模型发送自然语言提示来实现的。)

MCP 有五个核心组件:

  • MCP 主机:类似 Claude Desktop 的程序、IDE 或通过 MCP 获取数据的 AI 工具
  • MCP 客户端:与服务器保持一对一连接的协议客户端
  • MCP 服务器:使用标准化模型上下文协议提供特定功能的轻量级程序
  • 本地数据源:MCP 服务器可以安全访问的您计算机上的文件、数据库和服务
  • 远程服务:您可以在线找到的外部系统(例如通过 API),MCP 服务器可以连接到这些系统

现在您已经掌握了 MCP 的基础知识,并且对我们将要构建的内容有了足够的了解,让我们开始项目吧。


项目设置🛠️

💁 在本节中,我们将完成构建项目的所有先决条件。

初始化 Next.js 应用程序

使用以下命令初始化新的 Next.js 应用程序:

💁 你可以使用任何你喜欢的包管理器。在本项目中,我将使用 npm。

npx create-next-app@latest chat-nextjs-mcp-client \
--typescript --tailwind --eslint --app --src-dir --use-npm
Enter fullscreen mode Exit fullscreen mode

接下来,导航到新创建的 Next.js 项目:

cd chat-nextjs-mcp-client
Enter fullscreen mode Exit fullscreen mode

安装依赖项

我们需要一些依赖项。运行以下命令安装所有依赖项:

npm install composio-core openai dotenv @modelcontextprotocol/sdk
Enter fullscreen mode Exit fullscreen mode

Composio 设置

💁注意:如果您计划使用本地 MCP 服务器运行应用程序,则无需设置 Composio。如果您想测试一些很棒的托管 MCP 服务器(我将在本博客中介绍),请务必进行设置。(推荐)

首先,在继续之前,我们需要获取 Composio API 密钥。

继续,在 Composio 上创建一个帐户,获取 API 密钥,并将其粘贴到.env项目根目录中的文件中:

Composio API 密钥列表

COMPOSIO_API_KEY=<your_composio_api_key>
Enter fullscreen mode Exit fullscreen mode

现在,您需要安装composioCLI 应用程序,您可以使用以下命令执行此操作:

sudo npm i -g composio-core
Enter fullscreen mode Exit fullscreen mode

使用以下命令验证您的身份:

composio login
Enter fullscreen mode Exit fullscreen mode

完成后,运行该composio whoami命令,如果看到类似这样的内容,则表示您已成功登录。

Composio whoami 命令输出

现在,我们快完成了。为了演示,我计划使用 Gmail 和 Linear。要使用这些集成,我们首先需要对以下每个应用进行身份验证:

运行以下命令:

composio add linear
Enter fullscreen mode Exit fullscreen mode

并且,您应该看到类似这样的输出:

Composio 线性积分加法

转到显示的 URL,您将像这样进行身份验证:

Composio 线性积分成功

就是这样。我们添加了线性积分。同样,对 Gmail 进行同样的操作,你就可以跟着学习了。🎉

添加两个集成后,运行composio integrations命令,您应该会看到类似以下内容:

Composio 集成列表

💡注意:如果您想测试一些不同的集成,请随意进行。您可以随时继续操作,步骤都是一样的。

Shadcn UI 设置

对于可立即使用的 UI 组件集合,我们将使用shadcn/ui。运行以下命令使用默认设置初始化它:

npx shadcn@latest init -d
Enter fullscreen mode Exit fullscreen mode

我们不会使用太多的 UI 组件,只用四个。使用以下命令安装:

npx shadcn@latest add button tooltip card accordion
Enter fullscreen mode Exit fullscreen mode

这会在components/ui目录中创建四个名为
button.tsx和 的新文件tooltip.tsxaccordion.tsxcard.tsx


代码设置

💁 在本节中,我们将介绍创建简单聊天界面并将其连接到 Composio MCP 服务器以进行实际测试所需的所有编码。

初始设置

在中app/layout.tsx,更改以下代码行:(可选,如果您不想添加工具提示)

// ... Rest of the code
import { TooltipProvider } from "@/components/ui/tooltip";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <TooltipProvider delayDuration={0}>{children}</TooltipProvider>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

我们在这里所做的就是用包装子组件,<TooltipProvider />以便我们可以在应用程序中使用工具提示。

app/page.tsx现在,使用以下代码行进行编辑:

import { Chat } from "@/components/chat";

export default function Page() {
  return <Chat />;
}
Enter fullscreen mode Exit fullscreen mode

我们还没有<Chat />组件,所以首先,让我们创建它。

构建聊天组件

💁 该<Chat />组件负责整个聊天界面,因此我们可以与 LLM 对话并将聊天路由连接到应用程序。

在目录中components,创建一个名为的新文件chat.tsx并添加以下代码行:

"use client";

import { useState } from "react";
import { ArrowUpIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { AutoResizeTextarea } from "@/components/autoresize-textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion";

type Message = {
  role: "user" | "assistant";
  content: string | object;
  // eslint-disable-next-line  @typescript-eslint/no-explicit-any
  toolResponses?: any[];
};

export function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const userMessage: Message = { role: "user", content: input };
    setMessages((prev) => [...prev, userMessage]);
    setInput("");
    setLoading(true);

    // Add a temp loading msg for the llms....
    setMessages((prev) => [
      ...prev,
      { role: "assistant", content: "Hang on..." },
    ]);

    try {
      const res = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages: [...messages, userMessage] }),
      });

      const data = await res.json();
      const content =
        typeof data?.content === "string"
          ? data.content
          : "Sorry... got no response from the server";

      const toolResponses = Array.isArray(data?.toolResponses)
        ? data.toolResponses
        : [];

      setMessages((prev) => [
        ...prev.slice(0, -1),
        { role: "assistant", content, toolResponses },
      ]);
    } catch (err) {
      console.error("Chat error:", err);
      setMessages((prev) => [
        ...prev.slice(0, -1),
        { role: "assistant", content: "Oops! Something went wrong." },
      ]);
    } finally {
      setLoading(false);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSubmit(e as unknown as React.FormEvent<HTMLFormElement>);
    }
  };

  return (
    <main className="ring-none mx-auto flex h-svh max-h-svh w-full max-w-[35rem] flex-col items-stretch border-none">
      <div className="flex-1 overflow-y-auto px-6 py-6">
        {messages.length === 0 ? (
          <header className="m-auto flex max-w-96 flex-col gap-5 text-center">
            <h1 className="text-2xl font-semibold leading-none tracking-tight">
              MCP Powered AI Chatbot
            </h1>
            <p className="text-muted-foreground text-sm">
              This is an AI chatbot app built with{" "}
              <span className="text-foreground">Next.js</span>,{" "}
              <span className="text-foreground">MCP backend</span>
            </p>
            <p className="text-muted-foreground text-sm">
              Built with 🤍 by Shrijal Acharya (@shricodev)
            </p>
          </header>
        ) : (
          <div className="my-4 flex h-fit min-h-full flex-col gap-4">
            {messages.map((message, index) => (
              <div key={index} className="flex flex-col gap-2">
                {message.role === "assistant" &&
                  Array.isArray(message.toolResponses) &&
                  message.toolResponses.length > 0 && (
                    <Card>
                      <CardHeader>
                        <CardTitle className="text-base">
                          Tool Responses
                        </CardTitle>
                      </CardHeader>
                      <CardContent>
                        <Accordion type="multiple" className="w-full">
                          {message.toolResponses.map((toolRes, i) => (
                            <AccordionItem key={i} value={`item-${i}`}>
                              <AccordionTrigger>
                                Tool Call #{i + 1}
                              </AccordionTrigger>
                              <AccordionContent>
                                <pre className="whitespace-pre-wrap break-words text-sm">
                                  {JSON.stringify(toolRes, null, 2)}
                                </pre>
                              </AccordionContent>
                            </AccordionItem>
                          ))}
                        </Accordion>
                      </CardContent>
                    </Card>
                  )}

                <div
                  className="max-w-[80%] rounded-xl px-3 py-2 text-sm data-[role=assistant]:self-start data-[role=user]:self-end"
                  data-role={message.role}
                  style={{
                    alignSelf:
                      message.role === "user" ? "flex-end" : "flex-start",
                    backgroundColor:
                      message.role === "user" ? "#3b82f6" : "#f3f4f6",
                    color: message.role === "user" ? "white" : "black",
                  }}
                >
                  {typeof message.content === "string"
                    ? message.content
                    : JSON.stringify(message.content, null, 2)}
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      <form
        onSubmit={handleSubmit}
        className="border-input bg-background focus-within:ring-ring/10 relative mx-6 mb-6 flex items-center rounded-[16px] border px-3 py-1.5 pr-8 text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-0"
      >
        <AutoResizeTextarea
          onKeyDown={handleKeyDown}
          onChange={(e) => setInput(e)}
          value={input}
          placeholder="Enter a message"
          className="placeholder:text-muted-foreground flex-1 bg-transparent focus:outline-none"
        />
        <Tooltip>
          <TooltipTrigger asChild>
            <Button
              variant="ghost"
              size="sm"
              className="absolute bottom-1 right-1 size-6 rounded-full"
              disabled={!input.trim() || loading}
            >
              <ArrowUpIcon size={16} />
            </Button>
          </TooltipTrigger>
          <TooltipContent sideOffset={12}>Submit</TooltipContent>
        </Tooltip>
      </form>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

该组件本身非常简单;我们所做的就是将所有消息记录在状态变量中,每次用户提交表单时,我们都会POSTapi/chat端点发出请求,并在 UI 中显示响应。就是这样!

💁如果你意识到,我们还有另一个<AutoResizeTextarea />尚未添加的组件,这只是一个文本区域,但为什么不使用普通的呢<textarea />

原因很简单,纯文本区域看起来非常糟糕,不适合用作输入文本框。

在目录中components,创建一个名为的新文件autoresize-textarea.tsx并添加以下代码行:

"use client";

import { cn } from "@/lib/utils";
import React, { useRef, useEffect, type TextareaHTMLAttributes } from "react";

interface AutoResizeTextareaProps
  extends Omit<
    TextareaHTMLAttributes<HTMLTextAreaElement>,
    "value" | "onChange"
  > {
  value: string;
  onChange: (value: string) => void;
}

export function AutoResizeTextarea({
  className,
  value,
  onChange,
  ...props
}: AutoResizeTextareaProps) {
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const resizeTextarea = () => {
    const textarea = textareaRef.current;
    if (textarea) {
      textarea.style.height = "auto";
      textarea.style.height = `${textarea.scrollHeight}px`;
    }
  };

  useEffect(() => {
    resizeTextarea();
  }, [value]);

  return (
    <textarea
      {...props}
      value={value}
      ref={textareaRef}
      rows={1}
      onChange={(e) => {
        onChange(e.target.value);
        resizeTextarea();
      }}
      className={cn("resize-none min-h-4 max-h-80", className)}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

<AutoResizeTextarea />已经看起来更好了,并且可以根据消息的大小进行扩展,这是处理它的理想方式。

端点api/chat尚未实现,我们稍后会实现。

使用 Composio 设置 API 路由

api/chat💁 这是我们创建端点并将其与 Composio 托管的 MCP 服务器连接的部分。

创建app/api/chat目录并添加route.ts包含以下代码行的文件:

import { NextRequest, NextResponse } from "next/server";
import { OpenAI } from "openai";
import { OpenAIToolSet } from "composio-core";

const toolset = new OpenAIToolSet();
const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

export async function POST(req: NextRequest) {
  const { messages } = await req.json();

  const userQuery = messages[messages.length - 1]?.content;
  if (!userQuery) {
    return NextResponse.json(
      { error: "No user query found in request" },
      { status: 400 },
    );
  }

  try {
    const tools = await toolset.getTools({
      // You can directly specify multiple apps like so, but doing so might
      // result in > 128 tools, but openai limits on 128 tools
      // apps: ["gmail", "slack"],
      //
      // Or, single apps like so:
      // apps: ["gmail"],
      //
      // Or, directly specifying actions like so:
      // actions: ["GMAIL_SEND_EMAIL", "SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL"],

      // Gmail and Linear does not cross the tool limit of 128 when combined
      // together as well
      apps: ["gmail", "linear"],
    });
    console.log(
      `[DEBUG]: Tools length: ${tools.length}. Errors out if greater than 128`,
    );

    const fullMessages = [
      {
        role: "system",
        content: "You are a helpful assistant that can use tools.",
      },
      ...messages,
    ];

    const response = await client.chat.completions.create({
      model: "gpt-4o-mini",
      messages: fullMessages,
      tools,
      // tool_choice: "auto",
    });

    const aiMessage = response.choices[0].message;
    const toolCalls = aiMessage.tool_calls || [];

    if (toolCalls.length > 0) {
      const toolResponses = [];

      for (const toolCall of toolCalls) {
        const res = await toolset.executeToolCall(toolCall);
        toolResponses.push(res);
        console.log("[DEBUG]: Executed tool call:", res);
      }

      return NextResponse.json({
        role: "assistant",
        content: "Successfully executed tool call(s) 🎉🎉",
        toolResponses,
      });
    }

    return NextResponse.json({
      role: "assistant",
      content: aiMessage.content || "Sorry... got no response from the server",
    });
  } catch (err) {
    console.error(err);
    return NextResponse.json(
      { error: "Something went wrong!" },
      { status: 500 },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

现在,一切就绪。所有连接都已完成,可以运行了。您可以直接在聊天输入框中与 MCP 服务器聊天。

这是我通过 Composio 测试 Gmail 和 Linear MCP 服务器的演示:👇


自定义 MCP 服务器使用

上面,我们将聊天应用程序设置为使用 Composio 托管的 MCP,但如果您想使用自己的本地托管的 MCP 服务器进行测试,猜猜怎么着?这也是可能的。

本地 MCP 服务器设置

我们不会一起编写代码;你可以在任何地方找到许多本地 MCP 服务器。在这里,我们将使用一个 Filesystem MCP 服务器,它被放置在我存放最终代码的另一个分支中。

首先,确保您已退出 Next.js 客户端应用程序的上一级目录。然后,运行以下命令:

git clone --depth 1 --branch custom-fs-mcp-server \
https://github.com/shricodev/chat-nextjs-mcp-client.git chat-mcp-server
Enter fullscreen mode Exit fullscreen mode

现在,您应该有一个如下所示的文件夹结构:

.
├── chat-nextjs-mcp-client
│   ├── .git
│   ├── app
│   ├── components
│   ├── lib
│   ├── node_modules
│   ├── public
│   ├── ...
├── chat-mcp-server
│    └── custom-fs-mcp-server
│        ├── src
│        ├── .gitignore
│        ├── eslint.config.js
│        ├── package-lock.json
│        ├── package.json
│        └── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

您需要做的就是构建应用程序并将其路径传递给我们的 Next.js MCP 客户端中的聊天路由。

运行以下命令:

cd chat-mcp-server && npm run build
Enter fullscreen mode Exit fullscreen mode

这应该会构建应用程序并将index.js文件放入目录中build。记下该文件的路径index.js,因为我们在下一步中会用到它。

您可以使用以下命令获取文件的路径realpath

realpath chat-mcp-server/custom-fs-mcp-server/build/index.js
Enter fullscreen mode Exit fullscreen mode

本地服务器实用功能

以前,我们使用其包连接到 Composio 服务器,但为了连接到本地 MCP 服务器,我们需要手动初始化连接。

使用以下代码行创建lib/mcp-client/index.ts目录并添加文件:index.ts

import { OpenAI } from "openai";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import dotenv from "dotenv";

dotenv.config();

const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const MODEL_NAME = "gpt-4o-mini";

if (!OPENAI_API_KEY) throw new Error("OPENAI_API_KEY is not set");

const openai = new OpenAI({
  apiKey: OPENAI_API_KEY,
});
const mcp = new Client({ name: "nextjs-mcp-client", version: "1.0.0" });

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let tools: any[] = [];

let connected = false;

export async function initMCP(serverScriptPath: string) {
  if (connected) return;

  const command = serverScriptPath.endsWith(".py")
    ? process.platform === "win32"
      ? "python"
      : "python3"
    : process.execPath;

  const transport = new StdioClientTransport({
    command,
    args: [serverScriptPath, "/home/shricodev"],
  });

  mcp.connect(transport);

  const toolsResult = await mcp.listTools();

  tools = toolsResult.tools.map((tool) => ({
    type: "function",
    function: {
      name: tool.name,
      description: tool.description,
      parameters: tool.inputSchema,
    },
  }));

  connected = true;
  console.log(
    "MCP Connected with tools:",
    tools.map((t) => t.function.name),
  );
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function executeToolCall(toolCall: any) {
  const toolName = toolCall.function.name;
  const toolArgs = JSON.parse(toolCall.function.arguments || "{}");

  const result = await mcp.callTool({
    name: toolName,
    arguments: toolArgs,
  });

  return {
    id: toolCall.id,
    name: toolName,
    arguments: toolArgs,
    result: result.content,
  };
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function processQuery(messagesInput: any[]) {
  const messages: OpenAI.ChatCompletionMessageParam[] = [
    {
      role: "system",
      content: "You are a helpful assistant that can use tools.",
    },
    ...messagesInput,
  ];

  const response = await openai.chat.completions.create({
    model: MODEL_NAME,
    max_tokens: 1000,
    messages,
    tools,
  });

  const replyMessage = response.choices[0].message;
  const toolCalls = replyMessage.tool_calls || [];

  if (toolCalls.length > 0) {
    const toolResponses = [];

    for (const toolCall of toolCalls) {
      const toolResponse = await executeToolCall(toolCall);
      toolResponses.push(toolResponse);

      messages.push({
        role: "assistant",
        content: null,
        tool_calls: [toolCall],
      });

      messages.push({
        role: "tool",
        content: toolResponse.result as string,
        tool_call_id: toolCall.id,
      });
    }

    const followUp = await openai.chat.completions.create({
      model: MODEL_NAME,
      messages,
    });

    return {
      reply: followUp.choices[0].message.content || "",
      toolCalls,
      toolResponses,
    };
  }

  return {
    reply: replyMessage.content || "",
    toolCalls: [],
    toolResponses: [],
  };
}
Enter fullscreen mode Exit fullscreen mode

initMCP顾名思义,该函数启动与指定路径的 MCP 服务器的连接并获取所有可用的工具。

executeToolCall函数接受工具调用对象,解析参数,执行工具,并返回带有调用元数据的结果。

processQuery函数处理与 OpenAI 的所有聊天交互。如果 AI 决定运行工具,它会通过 MCP 运行这些工具,并发送包含工具结果的后续消息。

编辑聊天路线以使用本地 MCP 服务器

现在,我们快完成了。剩下的就是app/api/chat/route.ts使用以下代码行编辑聊天路由文件:

import { NextRequest, NextResponse } from "next/server";
import { initMCP, processQuery } from "@/lib/mcp-client";

const SERVER_PATH = "<previously_built_mcp_server_path_index_js>";

export async function POST(req: NextRequest) {
  const { messages } = await req.json();
  const userQuery = messages[messages.length - 1]?.content;

  if (!userQuery) {
    return NextResponse.json({ error: "No query provided" }, { status: 400 });
  }

  try {
    await initMCP(SERVER_PATH);

    const { reply, toolCalls, toolResponses } = await processQuery(messages);

    if (toolCalls.length > 0) {
      return NextResponse.json({
        role: "assistant",
        content: "Successfully executed tool call(s) 🎉🎉",
        toolResponses,
      });
    }

    return NextResponse.json({
      role: "assistant",
      content: reply,
    });
  } catch (err) {
    console.error("[MCP Error]", err);
    return NextResponse.json(
      { error: "Something went wrong" },
      { status: 500 },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

无需其他更改。一切顺利。现在,为了实际测试,请在 MCP 客户端目录中运行以下命令:

npm run dev
Enter fullscreen mode Exit fullscreen mode

您不需要单独运行服务器,因为客户端已经在单独的进程中启动它。

现在,您已全部设置完毕,可以使用 MCP 服务器中提供的所有工具。🚀


结论

哇哦!😮‍💨我们涵盖了很多内容,从使用 Next.js 构建支持 Composio 托管的 MCP 服务器的聊天 MCP 客户端,到使用我们自己本地托管的 MCP 服务器。

如果您在编写代码时迷失了方向,可以在这里找到源代码:

https://github.com/shricodev/chat-nextjs-mcp-client

非常感谢你的阅读!🎉 🫡

在下面的评论区分享你的想法!👇

鏂囩珷鏉ユ簮锛�https://dev.to/composiodev/build-your-own-chat-mcp-client-with-nextjs-4a0k
PREV
✨ Gemini 2.5 Pro 与 Claude 3.7 Sonnet 编码比较 🔥
NEXT
加快跨平台开发