首页 / 文章 / 将MCP工具集成到React聊天界面中,并内置人工审核功能

将MCP工具集成到React聊天界面中,并内置人工审核功能

了解模型上下文协议如何适配 React 应用:为何后端应托管 MCP、工具服务器的运作原理,以及如何在用户界面中流式处理并批准工具调用。

3444 词

在 React 应用程序中,AI 功能通常是逐个通过定制化集成逐步添加的,每个集成都有独立的 SDK、身份验证机制、错误处理方式以及数据映射逻辑,且这些组件之间紧密关联。Anthropic 推出并现已被广泛采用的开放协议——模型上下文协议(MCP)——用一个标准接口取代了这种复杂的结构,实现了 AI 应用程序与其所使用的工具和数据之间的统一交互;人们常将其比作 AI 领域的 USB-C 接口。本文将阐述 MCP 在 React 架构中的位置,介绍一个简单的数据库工具服务器及后端主机,最后构建由 React 开发者负责的部分:一个能够实时传输工具调用请求并让用户进行确认的聊天界面。

如果您想先了解协议层面的发现与调用基础知识,请参阅MCP如何帮助AI智能体发现并调用工具。本文的重点在于应用层和用户界面方面。

MCP标准化了什么

MCP规定了应用程序如何向大型语言模型提供上下文与功能。它将系统分为三种角色:主机即您的应用程序,客户端则是主机内部的连接器,负责与某台服务器保持会话连接,而服务器则负责提供工具和数据源。通常情况下,每连接一台服务器,主机会运行一个对应的客户端。

在没有MCP的情况下,基于AI的React应用程序的依赖项列表往往如下所示:

React App
├── OpenAI SDK (for chat)
├── Anthropic SDK (for reasoning)
├── LangChain (for RAG)
├── Custom API Client (for your database)
└── Custom API Client (for your CRM)

每个数据条目都有独立的身份验证、错误处理和架构映射机制;新的数据源意味着需要一个新的端点以及一个新的前端服务。

借助MCP,集成工作可简化为统一的模式:

React App (Host)
└── MCP Client
    ├── MCP Server: File System
    ├── MCP Server: PostgreSQL
    ├── MCP Server: Slack
    ├── MCP Server: Your Internal API
    └── MCP Server: Any Future Tool

所有服务器都遵循相同的协议。主机无需了解PostgreSQL或Slack API的细节,它只需提出两个通用问题:“有哪些可用工具?”以及“用这些参数运行该工具”,其余工作由协议处理。需要注意的是,MCP并未对工具和数据的连接方式进行标准化,仅对模型选择不做统一规定。主机仍然需要与你使用的LLM提供商直接通信。

三种基本功能

你的React应用可以直接与三种服务器功能交互,更常见的是通过后端来实现这种交互。

工具

工具是模型可以调用的功能。每个工具都有名称、描述以及用于说明其参数的 JSON Schema。当用户询问昨天创建了多少账户时,模型无需猜测:它可以找到像 query_user_signups 这样的工具,该工具接受 date 参数,调用它后即可根据结果给出答案。正是工具将用户用自然语言提出的问题与应用程序背后的数据联系起来。

资源

资源是模型可以读取的上下文内容,例如文件、数据库记录或聊天记录。每个资源都有对应的 URI,比如 file:///docs/spec.pdf 或 db://users/123,通过读取这些资源,模型能够基于真实数据而非训练数据来回答问题。

提示词

提示词是服务器发布的可重复使用的模板。某个服务器可能会提供一个需要file_path参数的code_review提示词;客户端获取该模板,填入参数后将结果发送给模型。

为何前端不仅仅是显示界面

直接传递式前端

在许多AI应用中,React客户端会将用户的消息发送到Node或FastAPI后端,后端再将其转发给模型提供方,等待响应后再传回。如果模型需要某些工具,后端也会负责处理。前端仅是一个被动的文本渲染器:它既不了解模型正在执行什么操作,也无法进行干预。

作为控制界面的前端

通过MCP风格的工具调用,用户界面可以实时显示工具调用过程,并允许用户在敏感操作执行前进行批准或拒绝。无论浏览器是自行维护MCP连接,还是更常见地从后端主机接收结构化数据流,React都是实现操作协调与控制的平台。

用户越来越期望拥有这种控制权:能够看到助手即将查询他们的数据,并先获得批准。而这样的体验正是通过React实现的。

MCP主机的部署位置

存在两种可行的架构,可根据安全性和延迟要求来选择。

后端中介的MCP——默认方案

在这种模式下,React 应用仅与后端通信,而后端则充当 MCP 主机。它保持与 MCP 服务器的连接畅通,处理身份验证并将工具操作结果传递给前端:

React (Client)  <--SSE/WS-->  FastAPI/Node (MCP Host)  <--stdio/SSE-->  MCP Servers

这些优势对大多数产品而言都至关重要:

  • 安全性:MCP 服务器的凭证始终保存在服务器上,不会传送到浏览器。
  • 状态管理:持久化的数据库和文件系统会话都保存在服务器上,处于应有的位置。
  • 可审计性:每个工具调用都可以被记录、限制频率,并关联到特定用户。

前端接收结构化的事件流(文本片段、工具调用请求、工具结果以及最终答案),并据此有针对性地渲染界面。

关于传输方式的说明:图表展示了主机与本地服务器之间的标准输入输出方式,以及远程服务器使用的SSE协议。MCP规范随着时间推移对其HTTP传输方式进行了改进,因此请查阅最新规范和SDK文档以了解推荐的远程传输方案。

仅适用于特定场景的浏览器原生MCP

另一种方法是,React应用可以通过基于HTTP的流式传输直接连接到远程MCP服务器。虽然这种方法可行,但生产环境中的团队很少选择它,因为这样一来数据层及其认证信息就会暴露在浏览器中。这类方式仅适用于本地开发者工具或不会处理敏感数据的全客户端AI应用。

用TypeScript实现的数据库工具服务器

构建一个小型服务器是理解该协议的最快途径。下面的示例使用官方的 TypeScript SDK,通过两个工具来暴露 PostgreSQL 的 users 表。内容分为三部分:首先,它创建一个连接池以及一个能够宣告 tools 功能的 MCP Server;其次,ListToolsRequestSchema 处理程序会为每个工具提供名称、描述以及 inputSchema,模型在决定调用哪个工具时会参考这些信息;再次,CallToolRequestSchema 处理程序会根据工具名称进行分发,执行参数化查询,并以文本形式返回查询结果;如果出现错误,则会返回 isError: true 以及相应的错误信息。最后,该服务器通过 stdio 进行通信,因此主机可以将其作为子进程启动。

// mcp-servers/database-server.ts
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { Pool } from "pg";
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});
const server = new Server(
  {
    name: "postgres-mcp-server",
    version: "1.0.0",
  },
  {
    capabilities: {
      tools: {},
    },
  }
);
// Define available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
  return {
    tools: [
      {
        name: "query_users",
        description: "Query the users table with filters",
        inputSchema: {
          type: "object",
          properties: {
            limit: { type: "number", description: "Max results" },
            status: { type: "string", enum: ["active", "inactive"] },
          },
          required: ["limit"],
        },
      },
      {
        name: "get_user_by_email",
        description: "Find a user by their email address",
        inputSchema: {
          type: "object",
          properties: {
            email: { type: "string" },
          },
          required: ["email"],
        },
      },
    ],
  };
});
// Handle tool execution
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;
  try {
    if (name === "query_users") {
      const result = await pool.query(
        "SELECT id, email, status, created_at FROM users WHERE status = $1 LIMIT $2",
        [args.status || "active", args.limit]
      );
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(result.rows, null, 2),
          },
        ],
      };
    }
    if (name === "get_user_by_email") {
      const result = await pool.query(
        "SELECT * FROM users WHERE email = $1",
        [args.email]
      );
      return {
        content: [
          {
            type: "text",
            text: JSON.stringify(result.rows[0] || null, null, 2),
          },
        ],
      };
    }
    throw new Error(`Unknown tool: ${name}`);
  } catch (error) {
    return {
      content: [
        {
          type: "text",
          text: `Error: ${error.message}`,
        },
      ],
      isError: true,
    };
  }
});
const transport = new StdioServerTransport();
await server.connect(transport);

注意设计中避免了什么:模型从不直接发送原始 SQL 语句。它只能选择一些特定的、有名称的操作,而且查询会使用占位符($1、$2),这样参数就无法注入 SQL 代码。通过 isError 将错误作为内容返回,可以让模型识别并解释失败原因,而不会导致整个会话崩溃。

在实际使用此类功能之前,需要完善几项细节。虽然 JSON Schema 可以描述输入格式,但处理程序仍应自行验证 args 的内容(例如使用 schema 库),因为该参数可能缺失或格式错误;同时还需对 args.limit 设置上限。函数 get_user_by_email 使用了 SELECT * 语句,这会将所有列传递给模型,包括密码哈希等敏感信息,因此应明确指定需要查询的列。另外,在严格的 TypeScript 环境中,catch 块中的 error 类型为 unknown,因此在读取 .message 之前需先对其进行类型检查。

Python 后端服务器

React 应用程序从不直接与那台服务器通信,而是由后端负责。以下是使用 Python MCP SDK 编写的简易主机类示例。connect() 方法说明了如何启动服务器进程(包括command、args以及传递DATABASE_URL的环境变量)、打开标准输入输出客户端、创建ClientSession、通过initialize()执行协议握手,随后调用list_tools()来查看服务器提供的功能。execute_tool()方法用于传递工具名称及参数,并返回结果中的第一条文本信息,而close()方法则用于终止会话并关闭进程。

# backend/mcp_host.py
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
import asyncio
import json
class MCPHost:
    def __init__(self):
        self.session = None
        self.tools = []

    async def connect(self):
        server_params = StdioServerParameters(
            command="node",
            args=["mcp-servers/database-server.ts"],
            env={"DATABASE_URL": os.getenv("DATABASE_URL")}
        )

        self._client = stdio_client(server_params)
        self._read, self._write = await self._client.__aenter__()
        self.session = await ClientSession(self._read, self._write).__aenter__()
        await self.session.initialize()

        # Discover available tools
        tools_result = await self.session.list_tools()
        self.tools = [tool.name for tool in tools_result.tools]

    async def execute_tool(self, tool_name: str, arguments: dict):
        result = await self.session.call_tool(tool_name, arguments)
        return result.content[0].text if result.content else None

    async def close(self):
        await self.session.__aexit__(None, None, None)
        await self._client.__aexit__(None, None, None)

该代码片段在运行前需要修复。它使用了os.getenv,但却没有导入os模块。它直接对.ts文件调用node,这只有在Node版本能够直接执行TypeScript时才有效;否则需先将该服务器代码编译为JavaScript,或使用支持TypeScript的运行器。虽然手动调用__aenter__和__aexit__也能实现功能,但使用async with语句块或AsyncExitStack更为安全,因为它们能确保在出错时进行资源清理。另外需要注意的是,传递给子进程的环境会替换父进程的环境,因此还需包含服务器所需的其它配置,比如PATH。

React端:流式处理与工具调用审批

这就是 React 开发者展现其独特能力的领域。后端会传输包含文本以及工具调用请求和工具结果的事件,而 UI 则会将这些内容转化为交互式元素。

下面的 ChatInterface 组件会保存一条条消息的列表,每条消息可能包含状态为 pending、approved、rejected 或 completed 的 toolCalls。当用户发送消息时,该组件会添加用户的消息内容,打开指向 /api/chat 的 EventSource,并在有新事件到达时逐步构建助手的回复。text 事件用于添加内容,tool_call 事件会新增一个待处理的工具调用,而 tool_result 事件则会记录处理结果并将对应的调用状态标记为 completed。每次有新事件发生时,该组件都会用新的内容替换状态中的助手回复,从而触发 React 的重新渲染。approveToolCall 函数会将决策结果发送到 /api/chat/approve-tool,并乐观地将该调用的状态改为 approved。

// components/ChatInterface.tsx
"use client";
import { useState, useRef, useCallback } from "react";
import { ToolCallCard } from "./ToolCallCard";
interface Message {
  id: string;
  role: "user" | "assistant";
  content: string;
  toolCalls?: ToolCall[];
  toolResults?: ToolResult[];
}
interface ToolCall {
  id: string;
  name: string;
  arguments: Record<string, any>;
  status: "pending" | "approved" | "rejected" | "completed";
}
export function ChatInterface() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const eventSourceRef = useRef<EventSource | null>(null);
  const sendMessage = useCallback(async (content: string) => {
    // Add user message
    const userMsg: Message = {
      id: `user-${Date.now()}`,
      role: "user",
      content,
    };
    setMessages((prev) => [...prev, userMsg]);
    // Open SSE connection to backend
    const es = new EventSource(
      `/api/chat?message=${encodeURIComponent(content)}`
    );
    eventSourceRef.current = es;
    let assistantMsg: Message = {
      id: `assistant-${Date.now()}`,
      role: "assistant",
      content: "",
      toolCalls: [],
    };
    es.onmessage = (event) => {
      const chunk = JSON.parse(event.data);
      switch (chunk.type) {
        case "text":
          assistantMsg.content += chunk.text;
          setMessages((prev) => {
            const filtered = prev.filter((m) => m.id !== assistantMsg.id);
            return [...filtered, { ...assistantMsg }];
          });
          break;
        case "tool_call":
          // Model wants to call a tool
          assistantMsg.toolCalls = [
            ...(assistantMsg.toolCalls || []),
            {
              id: chunk.tool_call_id,
              name: chunk.name,
              arguments: chunk.arguments,
              status: "pending",
            },
          ];
          setMessages((prev) => {
            const filtered = prev.filter((m) => m.id !== assistantMsg.id);
            return [...filtered, { ...assistantMsg }];
          });
          break;
        case "tool_result":
          // Tool execution completed
          assistantMsg.toolResults = [
            ...(assistantMsg.toolResults || []),
            {
              toolCallId: chunk.tool_call_id,
              result: chunk.result,
            },
          ];
          // Update the specific tool call status
          assistantMsg.toolCalls = assistantMsg.toolCalls?.map((tc) =>
            tc.id === chunk.tool_call_id
              ? { ...tc, status: "completed" }
              : tc
          );
          setMessages((prev) => {
            const filtered = prev.filter((m) => m.id !== assistantMsg.id);
            return [...filtered, { ...assistantMsg }];
          });
          break;
      }
    };
    es.onerror = () => {
      es.close();
    };
  }, []);
  const approveToolCall = useCallback(
    async (messageId: string, toolCallId: string) => {
      // Send approval to backend
      await fetch("/api/chat/approve-tool", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messageId, toolCallId }),
      });
      // Optimistically update UI
      setMessages((prev) =>
        prev.map((msg) => {
          if (msg.id !== messageId) return msg;
          return {
            ...msg,
            toolCalls: msg.toolCalls?.map((tc) =>
              tc.id === toolCallId ? { ...tc, status: "approved" } : tc
            ),
          };
        })
      );
    },
    []
  );
  return (
    <div className="flex flex-col h-screen max-w-3xl mx-auto">
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((msg) => (
          <div
            key={msg.id}
            className={`flex ${
              msg.role === "user" ? "justify-end" : "justify-start"
            }`}
          >
            <div
              className={`max-w-[80%] rounded-lg p-4 ${
                msg.role === "user"
                  ? "bg-blue-600 text-white"
                  : "bg-gray-100 text-gray-900"
              }`}
            >
              <p className="whitespace-pre-wrap">{msg.content}</p>
              {msg.toolCalls?.map((tool) => (
                <ToolCallCard
                  key={tool.id}
                  tool={tool}
                  onApprove={() => approveToolCall(msg.id, tool.id)}
                />
              ))}
            </div>
          </div>
        ))}
      </div>
      <div className="border-t p-4">
        <form
          onSubmit={(e) => {
            e.preventDefault();
            sendMessage(input);
            setInput("");
          }}
        >
          <input
            value={input}
            onChange={(e) => setInput(e.target.value)}
            placeholder="Ask about your data..."
            className="w-full rounded-lg border px-4 py-2"
          />
        </form>
      </div>
    </div>
  );
}

每次工具调用都会由一个小型展示组件来处理,该组件会显示工具名称、状态、格式化为 JSON 的参数,以及在调用处于等待状态时提供的“批准”和“拒绝”按钮:

// components/ToolCallCard.tsx
interface ToolCallCardProps {
  tool: {
    name: string;
    arguments: Record<string, any>;
    status: string;
  };
  onApprove: () => void;
}
export function ToolCallCard({ tool, onApprove }: ToolCallCardProps) {
  return (
    <div className="mt-3 rounded border border-yellow-300 bg-yellow-50 p-3">
      <div className="flex items-center justify-between">
        <span className="text-sm font-semibold text-yellow-800">
          🔧 Tool Request: {tool.name}
        </span>
        <span className="text-xs text-yellow-600 uppercase">
          {tool.status}
        </span>
      </div>

      <pre className="mt-2 text-xs bg-white p-2 rounded overflow-x-auto">
        {JSON.stringify(tool.arguments, null, 2)}
      </pre>
      {tool.status === "pending" && (
        <div className="mt-3 flex gap-2">
          <button
            onClick={onApprove}
            className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700"
          >
            Approve
          </button>
          <button className="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700">
            Reject
          </button>
        </div>
      )}
    </div>
  );
}

这就是抽象层优势的体现。用户界面并不知道query_users是在 PostgreSQL 上运行的,即便将来出现search_slack工具,该界面也无需更改。它只知道有工具调用正在等待处理、携带了哪些参数,以及需要由人工来做出决策。

上线前需填补的漏洞

该示例展示了界面的结构,但仍有几处漏洞需要解决:

  • 该批准操作必须在服务器端执行。后端需保留对该特定调用ID的请求处理,直到收到与已认证用户关联的批准信号。界面上的状态变化仅作为反馈;按照当前设计,无论点击何种按钮,都可能让tool_result被返回。
  • “拒绝”按钮没有对应的处理逻辑,应将其连接到能够指示服务器取消该调用并允许模型在无结果情况下继续运行的接口。
  • EventSource仅能发送GET请求,因此用户的消息会包含在查询字符串中,这不仅受URL长度限制的影响,还可能被记录到服务器和代理的日志中。而使用fetch发送POST请求并读取流式响应内容则可以避免这些问题。
  • 数据流应当明确结束。在最终的“完成”事件发生时关闭连接,组件卸载时也需关闭连接,并向用户显示onerror错误信息,而非默默关闭。
  • React团队使用MCP的集成检查清单

    在集成MCP之前,请先解决以下架构问题:

    谁负责管理MCP客户端?

    在生产环境中,由后端负责。MCP服务器通常需要凭证、持久连接以及有状态的会话。React应用应接收为UI设计的结构化事件流,而非原始的协议消息。

    如何审批工具调用?

    绝不能在未经明确确认的情况下让模型运行具有破坏性的工具。如果它请求执行类似 delete_user 的操作,界面必须设置确认步骤。这既关乎安全,也关乎用户信任。设计聊天界面时,应在工具调用到达时暂停流式传输,且只有在用户批准后才能继续;同时如前所述,必须在服务器端强制实现这一暂停机制。

    如何传输部分状态?

    可使用 SSE 或 WebSockets。一个回复会经历多个阶段:模型进行推理、请求工具、等待响应,然后再继续处理。用户界面应通过进度指示器、工具调用卡片以及以结构化数据而非普通文本形式呈现的工具结果,清晰地展示每个阶段。

    如何显示错误?

    MCP服务器出现故障:数据库连接中断,文件系统服务器也会出现权限错误。前端应当将这些情况作为结构化的错误事件接收,并以可修复的问题形式呈现,而非出现界面故障。

    工具是如何被发现的?

    应用程序应能适应现有的各种工具。当主机连接到新服务器时,它应将更新后的工具列表传递给前端,前端便可向用户展示当前可用的功能,比如查询账户、搜索文档或运行分析查询。

    MCP为前端工作带来了哪些变化?

    如果没有统一的协议,每个数据源、模型集成和工具都需要各自的连接方式,就像一个装满不匹配充电器的抽屉。MCP实现了标准化连接:数据变为由模式描述的工具,主机通过单一接口使用这些工具,而用户界面则将每次调用呈现为交互式元素。实际应用中:

    • 无需修改前端即可新增功能。只需将新的MCP服务器连接到主机,通用的工具调用界面就能立即展示相关工具。
    • 用户界面与模型解耦。由于它渲染的是稳定的事件流,更换模型提供方属于后端处理的事务;MCP保持工具层面的稳定性,由主机负责处理特定提供方的模型调用。
    • 能够理解工具调用和审批操作的聊天组件,其功能远比仅能渲染Markdown的组件强大。

    核心要点

    • 在后端运行MCP,将凭证、连接信息及审计日志存储于此,并向React推送结构化事件。
    • 通过经过验证的、参数化的有限操作构建工具服务器,仅返回模型所需的数据。
    • 在服务器端实施审批机制;界面状态仅为反馈,而非决策依据。
    • 将聊天流程设计为明确的几种状态(文本、待处理请求、结果、错误),这样新增工具无需编写新的界面代码。

    相关阅读

  • 使用 Promise.withResolvers() 在 Node.js 中协调 LLM 工具调用 — 了解 Promise.withResolvers() 如何解决在 Node.js Lambda 中调用 Bedrock 上的 Claude 时的工具调用协调问题,以及它未涵盖的超时、重试和限制等内容。
  • 利用 InversifyJS 和组合根实现与框架无关的前端依赖注入 — 如何选择 TypeScript 依赖注入容器,将每个领域封装为 ContainerModule,在一个组合根中连接所有组件,并将其与 React、Vue 和 Angular 相集成。
  • 使用 withFetch() 将 Angular HttpClient 迁移到 Fetch 后端 — 了解为何 Angular 的 HttpClient 正从 XMLHttpRequest 转向 fetch,withFetch() 如何实现边缘 SSR 和流式传输,以及这对拦截器有何影响。
  • 利用 Next.js、FastAPI、Credits 和 SSE 构建 AI 聊天 SaaS — 详细讲解包含 FastAPI 网关、信用记录系统、Redis 速率限制及 SSE 流式传输的全栈 AI 聊天架构,以及需要优先解决的缺陷。
  • 为真实网络流量强化 TypeScript MCP 服务器 — 探讨如何将 MCP 服务器从基于 stdio 的模式升级为具备会话隔离、OAuth 承载令牌认证、速率限制、错误处理机制以及注入防御功能的 Streamable HTTP 模式。