Підключення інструментів MCP до інтерфейсу чату у React з вбудованою людською перевіркою
Дізнайтеся, як Model Context Protocol інтегрується в додаток React: чому бекенд має хостувати MCP, як працює сервер інструментів, та як стрімувати та схвалювати виклики інструментів у користувацькому інтерфейсі.
Функції ШІ в додатку на React зазвичай розвиваються поступово, шляхом інтеграції окремих компонентів, кожен з яких має власний SDK, механізми автентифікації, обробку помилок та мапування даних, причому усі вони тісно пов’язані між собою. Протокол Model Context Protocol (MCP) — відкритий протокол, створений компанією Anthropic та зараз широко використовуваний — замінює цю складність єдиним стандартним інтерфейсом між додатками ШІ та інструментами та даними, які вони використовують; поширеною аналогією є порт USB-C для ШІ. У цій статті пояснюється місце MCP у архітектурі React, розглядається невеликий сервер для роботи з базою даних та хост-сервер, а потім створюється та частина, якою керують розробники React: інтерфейс чату, який транслює виклики до інструментів та просить користувача їх схвалити.
Якщо ви спочатку хочете ознайомитися з основами рівня протоколу щодо виявлення та виклику, перегляньте як MCP дозволяє агентам ШІ виявляти та викликати інструменти. Тут акцент робиться на стороні застосування та користувацького інтерфейсу.
Що стандартизує MCP
MCP визначає, як застосунки надають контекст та можливості великим мовним моделям. Він розділяє три ролі: хостом є ваш застосунок, клієнтом — з’єднувач усередині хоста, який підтримує сесію з одним сервером, а сервери відкривають доступ до інструментів та джерел даних. Хост зазвичай запускає по одному клієнту на кожен сервер, до якого підключається.
Без MCP список залежностей 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 чи структуру API Slack; він ставить два загальні запитання: «Які інструменти доступні?» та «Запустіть цей інструмент з цими аргументами», а протокол передає решту інформації. Зверніть увагу, що MCP не стандартизує спосіб підключення до інструментів та даних, а лише вибір моделі. Хост все одно спілкується з тим постачальником LLM, який ви використовуєте.
Три примітиви
Ваша React-додаток взаємодіє з трьома типами можливостей сервера — безпосередньо або, що частіше трапляється, через ваш бекенд.
Інструменти
Інструменти — це функції, які модель може викликати. Кожен з них має назву, опис та JSON Schema, що описує його параметри. Коли користувач запитує, скільки облікових записів було створено вчора, моделі не потрібно здогадуватися: вона може знайти інструмент на кшталт query_user_signups, який приймає параметр date, викликати його та отримати відповідь з результату. Саме інструменти пов’язують запит користувача мовою просторікої розмови з даними, що знаходяться у вашому додатку.
Ресурси
Ресурси — це елементи контексту, які модель може читати, такі як файл, запис бази даних або потік чату. Кожен з них має URI, наприклад file:///docs/spec.pdf або db://users/123, і читання їх дозволяє моделі ґрунтувати свої відповіді у реальних даних, а не лише у матеріалах навчання.
Запити
Шаблони запитів — це повторно використовувані формати, які публікуються сервером. Сервер може запропонувати шаблон code_review, який приймає аргумент file_path; хост завантажує цей шаблон, підставляє аргумент та надсилає результат моделі.
Чому фронтенд — це більше, ніж просто інтерфейс відображення
Фронтенд з прямою передачею даних
У багатьох додатках на основі ШІ клієнт 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 залишаються на сервері та ніколи не потрапляють у браузер.
- Стан: постійні сесії бази даних та файлової системи зберігаються на сервері, де їм і належить бути.
- Підзвітність: кожен виклик інструменту може бути задокументований, обмежений за частотою та прив’язаний до конкретного користувача.
Фронтенд отримує структурований потік подій (фрагменти тексту, запити на виклик інструментів, результати їх роботи та кінцева відповідь) та свідомо відображає кожен елемент стану.
Примітка щодо транспорту: на діаграмі показано stdio між хостом та локальними серверами, а також SSE для віддалених серверів. Специфікація MCP з часом удосконалювала свій HTTP-транспорт, тому перевірте поточну специфікацію та документацію SDK щодо рекомендованого транспорту для віддалених серверів.
MCP, нативний для браузера, для обмежених випадків
Як альтернативу, додаток на React може безпосередньо під’єднуватися до віддалених серверів MCP через потокову передачу даних на основі HTTP. Цей підхід працює, але команди, що розробляють продукти для серійного використання, рідко його обирають, оскільки шар даних та відповідні облікові дані стають доступними з браузера. Використовуйте його лише для локальних інструментів розробника чи повністю клієнтських AI-додатків, які не обробляють конфіденційних даних.
Сервер інструментів для баз даних на TypeScript
Створення невеликого сервера — це найшвидший шлях до розуміння протоколу. У наведеному нижче прикладі таблиця PostgreSQL users розкривається через два інструменти за допомогою офіційного SDK TypeScript. Читайте його у трьох частинах. По-перше, створюється пул з’єднань та MCP Server, який оголошує про наявність функціоналу tools. По-друге, обробник 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 (наприклад, за допомогою бібліотеки схем), оскільки він може бути відсутнім або мати неправильну структуру; також слід обмежити значення args.limit. Функція get_user_by_email виконує запит SELECT *, що передасть у модель усі стовпці, включаючи конфіденційну інформацію, таку як хеші паролів, тому краще вказувати конкретні стовпці. Крім того, у суворому режимі TypeScript значення error у блоку catch є типу unknown, тому його потрібно перевірити перед доступом до .message.
Хост бекенду на Python
Додаток React ніколи не взаємодіє безпосередньо з цим сервером; це робить бекенд. Ось мінімальний клас хоста, створений за допомогою Python MCP SDK. Функція connect() описує спосіб запуску процесу сервера (command, args та середовище, яке передає DATABASE_URL), відкриває клієнта stdio, ініціює 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. Він запускає node для обробки файлу з розширенням .ts, що працює лише тоді, коли версія Node дозволяє безпосередньо виконувати TypeScript; у іншому разі спочатку скомпілюйте сервер у JavaScript або використовуйте засіб для виконання коду TypeScript. Ручне викликання __aenter__ та __aexit__ працює, але використання блоків async with або об’єкта AsyncExitStack є безпечнішим, оскільки вони гарантують очищення при виникненні помилок. Також зверніть увагу, що середовище, передане дочірньому процесу, замінює середовище батьківського процесу, тому необхідно включити все інше, що потрібне серверу, наприклад PATH.
Сторона React: стрімінг та схвалення викликів інструментів
Саме тут розробники React виконують свою найбільш унікальну роботу. Бекенд надсилає потоки подій, які містять не лише текст, а й запити до інструментів та їх результати, а користувацький інтерфейс перетворює їх на інтерактивні елементи.
Компонент ChatInterface, наведений нижче, зберігає список повідомлень, кожне з яких може містити toolCalls із статусом pending, approved, rejected або completed. Коли користувач надсилає повідомлення, він додає його запис, відкриває EventSource до адреси /api/chat та формує повідомлення асистента у міру надходження подій. Подія text додається до контенту, подія tool_call додає запит до інструменту зі статусом pending, а подія 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 та може потрапити до журналів сервера та проксі. Запит типу POST, який використовуєfetchдля отримання потокового тіла відповіді, усуває обидві проблеми.
onerror замість того, щоб мовчки закривати його.Чек-лист інтеграції MCP для команд React
Вирішіть ці архітектурні питання перед інтеграцією MCP:
Хто відповідає за клієнт MCP?
У продакшені — бекенд. Сервери MCP зазвичай потребують облікових даних, постійних з’єднань та сеансів із станом. Додаток React має отримувати структурований потік подій, призначений для інтерфейсу користувача, а не сирі повідомлення протоколу.
Як схвалюються виклики інструментів?
Ніколи не дозволяйте моделі використовувати руйнівні інструменти без чіткого підтвердження. Якщо вона просить щось на кшталт delete_user, інтерфейс має показувати крок підтвердження. Це стосується як довіри користувача, так і безпеки. Сконструюйте чат так, щоб трансляція призупинялась під час виклику інструменту та продовжувалась лише після схвалення користувачем, причому, як зазначалося вище, ця призупинка має здійснюватися на сервері.
Як транслюються часткові стани?
Використовуйте SSE або WebSockets. Одна відповідь проходить кілька етапів: модель аналізує ситуацію, запитує інструмент, чекає на його результати, а потім продовжує роботу. Інтерфейс має чітко відображати кожен етап за допомогою індикатора прогресу, карток викликів інструментів та результатів, які подаються у вигляді структурованих даних, а не нерозбірливого тексту.
Як відображаються помилки?
Сервери MCP не працюють: з’єднання з базою даних втрачаються, а сервери файлової системи повідомляють про помилки дозволів. Фронтенд має отримувати ці повідомлення у вигляді структурованих подій помилок та представляти їх як проблеми, які можна вирішити, а не як зламаний інтерфейс.
Як виявляються інструменти?
Додаток має адаптуватися до наявних інструментів. Коли хост під’єднується до нового сервера, він має надати оновлений список інструментів фронтенду, який потім може відображати поточні функції для користувачів, такі як пошук облікових записів, пошук документів чи виконання аналітичних запитів.
Які зміни MCP впливають на роботу фронтенду
Без спільного протоколу кожне джерело даних, інтеграція моделей та інструменти потребують власних механізмів з’єднання, наче шухляда, наповнена непідходящими зарядними пристроями. MCP стандартизує це з’єднання: дані перетворюються на інструменти, описані схемою, хости споживають їх через один інтерфейс, а користувацький інтерфейс представляє кожен виклик у вигляді інтерактивного елемента. На практиці:
- Нові функції можуть з’явитися без змін у фронтенді. Достатньо під’єднати новий сервер MCP до хоста, і універсальний користувацький інтерфейс для викликів інструментів може негайно показати їх.
- Користувацький інтерфейс відокремлений від моделі. Оскільки він відображає стабільний потік подій, зміна постачальників моделей є справою бекенду; MCP зберігає стабільність інструментів, тоді як хост обробляє виклики моделей, специфічні для конкретного постачальника.
- Компонент чату, який розуміє виклики інструментів та процедури схвалення, виконує набагато більше функцій, ніж той, що відображає Markdown.
Основні висновки
- Розмістіть MCP на бекенді, де знаходяться облікові дані, підключення та журнали аудиту, і передавайте структуровані події до React.
- Створюйте сервери інструментів на основі вузьких, перевірених та параметризованих операцій, які повертають лише те, що потрібно моделі.
- Забезпечуйте схвалення на сервері; статус інтерфейсу є лише зворотним зв’язком, а не механізмом контролю.
- Моделюйте чат за допомогою чітких станів (текст, очікування дзвінка, результат, помилка), щоб новим інструментам не потрібен був новий код інтерфейсу.
Пов’язана література
- Зміцнення Python LangChain Agent за допомогою семи вбудованих мідлвейрів — Дізнайтеся, як мідлвейри LangChain 1.0 додають функції узагальнення, обмежень на кількість викликів, повторних спроб, альтернативних моделей, маскування персональних даних та людського схвалення до агента Gemini, не торкаючись його основної логіки.