feat(chat): MCP stdio 工具接入

- McpServer:启动进程、initialize、tools/list、tools/call
- McpManager:多服务器、工具名冲突处理、OpenAI function 定义
- ChatClient:请求携带 tools,流式累积 tool_calls,结束后交回上层
- ChatWindow:工具调用循环(最多 5 轮)、scaffold 消息、设置里的 MCP JSON 编辑
- 无头自测 gxde-ai-see-mcptest + tests/mcp/test_server.py 验证通过
This commit is contained in:
2026-10-30 20:22:14 +08:00
parent c7865fcbf0
commit 77a4369cd6
11 changed files with 681 additions and 8 deletions
+123 -1
View File
@@ -4,6 +4,7 @@
#include "chatwindow.h"
#include "config.h"
#include "mcpclient.h"
#include <QCheckBox>
#include <QClipboard>
@@ -213,6 +214,7 @@ ChatWindow::ChatWindow(QWidget *parent)
m_storePath = Config::instance()->dataDir() + QStringLiteral("/chat.json");
setupUi();
McpManager::instance()->reload();
load();
m_current = m_conversations.isEmpty() ? -1 : qBound(0, m_current, m_conversations.size() - 1);
updateSidebar();
@@ -232,12 +234,38 @@ ChatWindow::ChatWindow(QWidget *parent)
});
connect(m_client, &ChatClient::finished, this, [this](const QString &) {
m_streamingIndex = -1;
m_toolScaffold = QJsonArray();
m_toolRounds = 0;
if (m_current >= 0) {
m_conversations[m_current].updatedAt = QDateTime::currentMSecsSinceEpoch();
}
save();
renderConversation();
});
connect(m_client, &ChatClient::toolCallsReady, this, [this](const QVector<ToolCall> &calls) {
if (m_current < 0 || m_streamingIndex < 0) {
return;
}
if (m_toolRounds >= 5) {
appendError(tr("工具调用次数过多,已停止"));
m_streamingIndex = -1;
return;
}
++m_toolRounds;
QStringList names;
for (const ToolCall &call : calls) {
names.append(call.name);
}
ChatMessage &message =
m_conversations[m_current].messages[m_streamingIndex];
message.content += QStringLiteral("%1🔧 调用工具:%2")
.arg(message.content.isEmpty() ? QString() : QStringLiteral("\n\n"),
names.join(QStringLiteral(", ")));
renderConversation();
runToolCalls(calls, 0, QJsonArray());
});
connect(m_client, &ChatClient::onceFinished, this, [this](const QString &summary) {
m_compressing = false;
if (m_current >= 0) {
@@ -341,6 +369,13 @@ void ChatWindow::setupUi()
auto *menu = new QMenu(menuButton);
menu->addAction(tr("压缩上下文"), this, [this]() { beginCompression(false); });
menu->addAction(tr("重试上一条"), this, &ChatWindow::retryLast);
menu->addAction(tr("重新加载 MCP"), this, [this]() {
McpManager::instance()->reload();
const QStringList status = McpManager::instance()->statusLines();
m_status->setText(status.isEmpty() ? tr("没有配置 MCP 服务器")
: status.join(QStringLiteral(" · ")));
m_status->show();
});
menu->addSeparator();
menu->addAction(tr("关于"), this, &ChatWindow::showAbout);
menuButton->setMenu(menu);
@@ -620,6 +655,8 @@ void ChatWindow::sendMessage()
conversation.messages.append(ChatMessage{ QStringLiteral("user"), text });
conversation.updatedAt = QDateTime::currentMSecsSinceEpoch();
m_skipCompress = false;
m_toolScaffold = QJsonArray();
m_toolRounds = 0;
m_input->clear();
m_status->hide();
@@ -669,7 +706,72 @@ void ChatWindow::sendPayload()
payload.append(conversation.messages.at(i));
}
m_client->send(payload);
if (m_toolRounds >= 5) {
m_client->setTools(QJsonArray());
} else {
m_client->setTools(McpManager::instance()->toolDefinitions());
}
m_client->send(payload, m_toolScaffold);
}
void ChatWindow::runToolCalls(const QVector<ToolCall> &calls, int index,
const QJsonArray &results)
{
if (index >= calls.size()) {
QJsonObject assistant;
assistant.insert(QStringLiteral("role"), QStringLiteral("assistant"));
assistant.insert(QStringLiteral("content"), QJsonValue::Null);
QJsonArray toolCalls;
for (const ToolCall &call : calls) {
QJsonObject function;
function.insert(QStringLiteral("name"), call.name);
function.insert(QStringLiteral("arguments"), call.arguments);
toolCalls.append(QJsonObject{ { QStringLiteral("id"), call.id },
{ QStringLiteral("type"), QStringLiteral("function") },
{ QStringLiteral("function"), function } });
}
assistant.insert(QStringLiteral("tool_calls"), toolCalls);
m_toolScaffold.append(assistant);
for (const QJsonValue &result : results) {
m_toolScaffold.append(result);
}
sendPayload();
return;
}
const ToolCall call = calls.at(index);
QJsonObject arguments;
const QJsonDocument document = QJsonDocument::fromJson(call.arguments.toUtf8());
if (document.isObject()) {
arguments = document.object();
}
McpManager::instance()->callTool(
call.name, arguments,
[this, calls, index, results](const QJsonObject &response) {
QString text;
const QJsonArray content = response.value(QStringLiteral("content")).toArray();
for (const QJsonValue &value : content) {
const QJsonObject object = value.toObject();
if (object.value(QStringLiteral("type")).toString() ==
QStringLiteral("text")) {
text += object.value(QStringLiteral("text")).toString();
text += QLatin1Char('\n');
}
}
if (text.isEmpty()) {
text = response.contains(QStringLiteral("error"))
? response.value(QStringLiteral("error")).toString()
: QString::fromUtf8(
QJsonDocument(response).toJson(QJsonDocument::Compact));
}
QJsonArray next = results;
next.append(QJsonObject{ { QStringLiteral("role"), QStringLiteral("tool") },
{ QStringLiteral("tool_call_id"), calls.at(index).id },
{ QStringLiteral("content"), text } });
runToolCalls(calls, index + 1, next);
});
}
bool ChatWindow::needsCompression() const
@@ -900,6 +1002,16 @@ void ChatWindow::openSettings()
Config::instance()->getBool(QStringLiteral("chat/autoCompress"), true));
form->addRow(QString(), autoCompressBox);
auto *mcpEdit = new QPlainTextEdit(&dialog);
mcpEdit->setFixedHeight(90);
mcpEdit->setPlaceholderText(
QStringLiteral("[{\"name\":\"fs\",\"command\":\"npx\","
"\"args\":[\"-y\",\"@modelcontextprotocol/server-filesystem\","
"\"/tmp\"]}]"));
mcpEdit->setPlainText(
Config::instance()->get(QStringLiteral("chat/mcpServers")).toString());
form->addRow(tr("MCP 服务器(JSON)"), mcpEdit);
auto *systemEdit = new QPlainTextEdit(&dialog);
systemEdit->setFixedHeight(80);
systemEdit->setPlainText(Config::instance()
@@ -933,6 +1045,16 @@ void ChatWindow::openSettings()
Config::instance()->set(QStringLiteral("chat/contextLength"), contextSpin->value());
Config::instance()->set(QStringLiteral("chat/autoCompress"),
autoCompressBox->isChecked());
const QJsonDocument mcpDocument =
QJsonDocument::fromJson(mcpEdit->toPlainText().toUtf8());
if (mcpDocument.isArray() || mcpEdit->toPlainText().trimmed().isEmpty()) {
Config::instance()->set(QStringLiteral("chat/mcpServers"),
mcpEdit->toPlainText().trimmed());
McpManager::instance()->reload();
} else {
m_status->setText(tr("MCP 配置不是合法的 JSON 数组,已忽略"));
m_status->show();
}
Config::instance()->sync();
renderConversation();
}