🎉 使用 Next.js 构建你自己的聊天 MCP 客户端⚡
厌倦了用 Next.js 构建千篇一律的 AI 聊天机器人应用程序?😪
我敢打赌你是的,但是如何构建一个由 MCP 驱动的聊天应用程序,可以同时连接远程和本地 MCP 服务器呢?
如果这听起来很有趣,请继续关注博客,我们将共同构建一个超酷的 MCP 驱动的 AI 聊天机器人,它支持多工具调用,有点类似于Claude和Windsurf。
让我们立即开始吧!
涵盖哪些内容?
在这个简单易懂的教程中,您将学习如何使用 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
接下来,导航到新创建的 Next.js 项目:
cd chat-nextjs-mcp-client
安装依赖项
我们需要一些依赖项。运行以下命令安装所有依赖项:
npm install composio-core openai dotenv @modelcontextprotocol/sdk
Composio 设置
💁注意:如果您计划使用本地 MCP 服务器运行应用程序,则无需设置 Composio。如果您想测试一些很棒的托管 MCP 服务器(我将在本博客中介绍),请务必进行设置。(推荐)
首先,在继续之前,我们需要获取 Composio API 密钥。
继续,在 Composio 上创建一个帐户,获取 API 密钥,并将其粘贴到.env
项目根目录中的文件中:
COMPOSIO_API_KEY=<your_composio_api_key>
现在,您需要安装composio
CLI 应用程序,您可以使用以下命令执行此操作:
sudo npm i -g composio-core
使用以下命令验证您的身份:
composio login
完成后,运行该composio whoami
命令,如果看到类似这样的内容,则表示您已成功登录。
现在,我们快完成了。为了演示,我计划使用 Gmail 和 Linear。要使用这些集成,我们首先需要对以下每个应用进行身份验证:
运行以下命令:
composio add linear
并且,您应该看到类似这样的输出:
转到显示的 URL,您将像这样进行身份验证:
就是这样。我们添加了线性积分。同样,对 Gmail 进行同样的操作,你就可以跟着学习了。🎉
添加两个集成后,运行composio integrations
命令,您应该会看到类似以下内容:
💡注意:如果您想测试一些不同的集成,请随意进行。您可以随时继续操作,步骤都是一样的。
Shadcn UI 设置
对于可立即使用的 UI 组件集合,我们将使用shadcn/ui。运行以下命令使用默认设置初始化它:
npx shadcn@latest init -d
我们不会使用太多的 UI 组件,只用四个。使用以下命令安装:
npx shadcn@latest add button tooltip card accordion
这会在components/ui
目录中创建四个名为button.tsx
、和 的新文件tooltip.tsx
。accordion.tsx
card.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>
);
}
我们在这里所做的就是用包装子组件,<TooltipProvider />
以便我们可以在应用程序中使用工具提示。
app/page.tsx
现在,使用以下代码行进行编辑:
import { Chat } from "@/components/chat";
export default function Page() {
return <Chat />;
}
我们还没有<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>
);
}
该组件本身非常简单;我们所做的就是将所有消息记录在状态变量中,每次用户提交表单时,我们都会POST
向api/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)}
/>
);
}
这<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 },
);
}
}
现在,一切就绪。所有连接都已完成,可以运行了。您可以直接在聊天输入框中与 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
现在,您应该有一个如下所示的文件夹结构:
.
├── 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
您需要做的就是构建应用程序并将其路径传递给我们的 Next.js MCP 客户端中的聊天路由。
运行以下命令:
cd chat-mcp-server && npm run build
这应该会构建应用程序并将index.js
文件放入目录中build
。记下该文件的路径index.js
,因为我们在下一步中会用到它。
您可以使用以下命令获取文件的路径realpath
:
realpath chat-mcp-server/custom-fs-mcp-server/build/index.js
本地服务器实用功能
以前,我们使用其包连接到 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: [],
};
}
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 },
);
}
}
无需其他更改。一切顺利。现在,为了实际测试,请在 MCP 客户端目录中运行以下命令:
npm run dev
您不需要单独运行服务器,因为客户端已经在单独的进程中启动它。
现在,您已全部设置完毕,可以使用 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在下面的评论区分享你的想法!👇