From 77a4369cd66508b8e175daf80229a91f2aa27630 Mon Sep 17 00:00:00 2001 From: shenmo Date: Fri, 30 Oct 2026 20:22:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20MCP=20stdio=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 验证通过 --- CHANGELOG.md | 3 +- CMakeLists.txt | 9 ++ README.md | 2 +- src/chat/chatclient.cpp | 51 ++++++- src/chat/chatclient.h | 14 +- src/chat/chatwindow.cpp | 124 ++++++++++++++++- src/chat/chatwindow.h | 4 + src/chat/mcpclient.cpp | 284 +++++++++++++++++++++++++++++++++++++++ src/chat/mcpclient.h | 93 +++++++++++++ tests/mcp/test_server.py | 45 +++++++ tests/mcptest.cpp | 60 +++++++++ 11 files changed, 681 insertions(+), 8 deletions(-) create mode 100644 src/chat/mcpclient.cpp create mode 100644 src/chat/mcpclient.h create mode 100644 tests/mcp/test_server.py create mode 100644 tests/mcptest.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 446fc18..06c5e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ - 文件/图片:打开、复制路径、保存图片、占位 OCR/描述 - 待办窗口:layer-shell、不抢焦点、复选框/删除/新建、持久化 - 小埃同学对话:DeepSeek/自定义 API 流式输出、多会话、设置、三块拖放区、 - 编辑/重试/分支、上下文条数与自动压缩、关于 + 编辑/重试/分支、上下文条数与自动压缩、Markdown、关于 +- MCP:stdio JSON-RPC,自动 initialize / tools-list,工具以 function calling 暴露给模型并在对话中执行 - 合成器 portal(flake-wm / gxde-wlcom):org.gxde.Portal、缩略图坐标映射、 原生 drop 到目标窗口 - gxde-k9 开机自启与保活开关;不支持环境只提示一次并保持待命 diff --git a/CMakeLists.txt b/CMakeLists.txt index 1ba0901..b2d97c1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,7 @@ set(GXDE_AI_SEE_SOURCES src/todo/todowindow.cpp src/chat/chatclient.cpp src/chat/chatwindow.cpp + src/chat/mcpclient.cpp src/ui/portalpanel.cpp ) @@ -73,6 +74,14 @@ add_executable(gxde-ai-see-selftest target_include_directories(gxde-ai-see-selftest PRIVATE src ${ICU_INCLUDE_DIRS}) target_link_libraries(gxde-ai-see-selftest PRIVATE Qt6::Core Qt6::Gui ${ICU_LIBRARIES}) +add_executable(gxde-ai-see-mcptest + tests/mcptest.cpp + src/chat/mcpclient.cpp + src/config.cpp +) +target_include_directories(gxde-ai-see-mcptest PRIVATE src) +target_link_libraries(gxde-ai-see-mcptest PRIVATE Qt6::Core) + install(TARGETS gxde-ai-see RUNTIME DESTINATION bin) install(FILES data/gxde-ai-see.desktop data/gxde-ai-see-todo.desktop DESTINATION share/applications) install(DIRECTORY data/icons/ DESTINATION share/icons/hicolor) diff --git a/README.md b/README.md index 97a429c..0fa379c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ - 多对话管理、流式输出、Markdown、代码块。 - 编辑消息、重试、从任意消息 fork。 - 上下文长度设置 + 手动/自动压缩。 -- MCP(stdio)工具接入。 +- MCP(stdio)工具接入:设置里填服务器 JSON,工具自动以 function calling 暴露给模型。 - 独立桌面入口;后续作为 GXDE 各项能力的 AI 入口与 skill 平台。 --- diff --git a/src/chat/chatclient.cpp b/src/chat/chatclient.cpp index 8bc6241..a44fb01 100644 --- a/src/chat/chatclient.cpp +++ b/src/chat/chatclient.cpp @@ -15,6 +15,7 @@ namespace { bool prepareRequest(const QVector &messages, bool stream, + const QJsonArray &tools, const QJsonArray &extraMessages, QNetworkRequest *request, QByteArray *body, QString *error) { const QString apiKey = @@ -49,8 +50,16 @@ bool prepareRequest(const QVector &messages, bool stream, Config::instance() ->get(QStringLiteral("chat/model"), QStringLiteral("deepseek-chat")) .toString()); - payload.insert(QStringLiteral("messages"), jsonMessages); + QJsonArray allMessages = jsonMessages; + for (const QJsonValue &extra : extraMessages) { + allMessages.append(extra); + } + payload.insert(QStringLiteral("messages"), allMessages); payload.insert(QStringLiteral("stream"), stream); + if (!tools.isEmpty()) { + payload.insert(QStringLiteral("tools"), tools); + payload.insert(QStringLiteral("tool_choice"), QStringLiteral("auto")); + } payload.insert(QStringLiteral("temperature"), 0.7); *request = QNetworkRequest{QUrl(baseUrl)}; @@ -86,17 +95,23 @@ ChatClient::ChatClient(QObject *parent) m_manager.setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy); } -void ChatClient::send(const QVector &messages) +void ChatClient::setTools(const QJsonArray &tools) +{ + m_tools = tools; +} + +void ChatClient::send(const QVector &messages, const QJsonArray &extraMessages) { cancel(); m_buffer.clear(); m_fullText.clear(); + m_toolCalls.clear(); m_done = false; QNetworkRequest request; QByteArray body; QString error; - if (!prepareRequest(messages, true, &request, &body, &error)) { + if (!prepareRequest(messages, true, m_tools, extraMessages, &request, &body, &error)) { Q_EMIT failed(error); return; } @@ -117,7 +132,7 @@ void ChatClient::sendOnce(const QVector &messages) QNetworkRequest request; QByteArray body; QString error; - if (!prepareRequest(messages, false, &request, &body, &error)) { + if (!prepareRequest(messages, false, QJsonArray(), QJsonArray(), &request, &body, &error)) { Q_EMIT onceFailed(error); return; } @@ -224,6 +239,26 @@ void ChatClient::handlePayload(const QByteArray &payload) m_fullText += content; Q_EMIT delta(content); } + + const QJsonArray toolCalls = deltaObject.value(QStringLiteral("tool_calls")).toArray(); + for (const QJsonValue &value : toolCalls) { + const QJsonObject object = value.toObject(); + const int index = object.value(QStringLiteral("index")).toInt(); + while (m_toolCalls.size() <= index) { + m_toolCalls.append(ToolCall{}); + } + ToolCall &call = m_toolCalls[index]; + if (object.contains(QStringLiteral("id"))) { + call.id = object.value(QStringLiteral("id")).toString(); + } + const QJsonObject function = object.value(QStringLiteral("function")).toObject(); + if (function.contains(QStringLiteral("name"))) { + call.name += function.value(QStringLiteral("name")).toString(); + } + if (function.contains(QStringLiteral("arguments"))) { + call.arguments += function.value(QStringLiteral("arguments")).toString(); + } + } } void ChatClient::handleFinished() @@ -254,6 +289,14 @@ void ChatClient::handleFinished() m_buffer.clear(); } + if (!m_toolCalls.isEmpty()) { + const QVector calls = m_toolCalls; + m_toolCalls.clear(); + Q_EMIT toolCallsReady(calls); + reply->deleteLater(); + return; + } + Q_EMIT finished(m_fullText); reply->deleteLater(); } diff --git a/src/chat/chatclient.h b/src/chat/chatclient.h index 95824fe..2c13326 100644 --- a/src/chat/chatclient.h +++ b/src/chat/chatclient.h @@ -3,6 +3,7 @@ #pragma once +#include #include #include #include @@ -14,6 +15,13 @@ struct ChatMessage QString content; }; +struct ToolCall +{ + QString id; + QString name; + QString arguments; +}; + /** * Minimal client for the DeepSeek (OpenAI compatible) chat API. * @@ -28,7 +36,8 @@ public: explicit ChatClient(QObject *parent = nullptr); bool busy() const { return m_reply != nullptr; } - void send(const QVector &messages); + void setTools(const QJsonArray &tools); + void send(const QVector &messages, const QJsonArray &extraMessages = QJsonArray()); void sendOnce(const QVector &messages); void cancel(); @@ -38,6 +47,7 @@ signals: void failed(const QString &error); void onceFinished(const QString &text); void onceFailed(const QString &error); + void toolCallsReady(const QVector &calls); private: void handleReadyRead(); @@ -48,6 +58,8 @@ private: class QNetworkReply *m_reply = nullptr; class QNetworkReply *m_onceReply = nullptr; QByteArray m_buffer; + QJsonArray m_tools; + QVector m_toolCalls; QString m_fullText; bool m_done = false; }; diff --git a/src/chat/chatwindow.cpp b/src/chat/chatwindow.cpp index cca01d4..1133b38 100644 --- a/src/chat/chatwindow.cpp +++ b/src/chat/chatwindow.cpp @@ -4,6 +4,7 @@ #include "chatwindow.h" #include "config.h" +#include "mcpclient.h" #include #include @@ -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 &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 &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(); } diff --git a/src/chat/chatwindow.h b/src/chat/chatwindow.h index 22085ab..ec39894 100644 --- a/src/chat/chatwindow.h +++ b/src/chat/chatwindow.h @@ -3,6 +3,7 @@ #pragma once +#include #include #include @@ -58,6 +59,7 @@ private: void updateSidebar(); void sendMessage(); void requestCompletion(); + void runToolCalls(const QVector &calls, int index, const QJsonArray &results); void sendPayload(); void editMessage(int index); void retryLast(); @@ -83,5 +85,7 @@ private: bool m_compressing = false; bool m_afterCompressSend = false; bool m_skipCompress = false; + QJsonArray m_toolScaffold; + int m_toolRounds = 0; QString m_storePath; }; diff --git a/src/chat/mcpclient.cpp b/src/chat/mcpclient.cpp new file mode 100644 index 0000000..d87e68b --- /dev/null +++ b/src/chat/mcpclient.cpp @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "mcpclient.h" + +#include +#include +#include +#include +#include + +#include "config.h" + +McpServer::McpServer(const QString &name, const QString &command, const QStringList &args, + QObject *parent) + : QObject(parent) + , m_name(name) + , m_command(command) + , m_args(args) +{ + m_process = new QProcess(this); + m_process->setProcessChannelMode(QProcess::SeparateChannels); + + connect(m_process, &QProcess::readyReadStandardOutput, this, [this]() { + m_buffer.append(m_process->readAllStandardOutput()); + int newline; + while ((newline = m_buffer.indexOf('\n')) >= 0) { + QByteArray line = m_buffer.left(newline); + m_buffer.remove(0, newline + 1); + line = line.trimmed(); + if (!line.isEmpty()) { + handleLine(line); + } + } + }); + connect(m_process, &QProcess::readyReadStandardError, this, [this]() { + const QByteArray error = m_process->readAllStandardError(); + if (!error.isEmpty()) { + qWarning().noquote() << QStringLiteral("[mcp:%1]").arg(m_name) + << QString::fromUtf8(error).trimmed(); + } + }); + connect(m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + qWarning().noquote() << QStringLiteral("[mcp:%1] process error %2").arg(m_name).arg(error); + }); +} + +McpServer::~McpServer() +{ + if (m_process && m_process->state() != QProcess::NotRunning) { + m_process->terminate(); + if (!m_process->waitForFinished(1000)) { + m_process->kill(); + m_process->waitForFinished(500); + } + } +} + +void McpServer::start() +{ + m_process->start(m_command, m_args); + if (!m_process->waitForStarted(3000)) { + qWarning().noquote() << QStringLiteral("[mcp:%1] failed to start %2").arg(m_name, m_command); + return; + } + + const int initId = m_nextId++; + m_callbacks.insert(initId, [this](const QJsonObject &) { + send(QJsonObject{ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("method"), + QStringLiteral("notifications/initialized") } }); + + const int toolsId = m_nextId++; + m_callbacks.insert(toolsId, [this](const QJsonObject &response) { + m_tools = response.value(QStringLiteral("result")) + .toObject() + .value(QStringLiteral("tools")) + .toArray(); + m_ready = true; + Q_EMIT readyChanged(); + }); + send(QJsonObject{ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), toolsId }, + { QStringLiteral("method"), QStringLiteral("tools/list") }, + { QStringLiteral("params"), QJsonObject{} } }); + }); + + QJsonObject params{ + { QStringLiteral("protocolVersion"), QStringLiteral("2024-11-05") }, + { QStringLiteral("capabilities"), QJsonObject{} }, + { QStringLiteral("clientInfo"), + QJsonObject{ { QStringLiteral("name"), QStringLiteral("gxde-ai-see") }, + { QStringLiteral("version"), QStringLiteral("0.1.0") } } }, + }; + send(QJsonObject{ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), initId }, + { QStringLiteral("method"), QStringLiteral("initialize") }, + { QStringLiteral("params"), params } }); +} + +void McpServer::send(const QJsonObject &message) +{ + if (!m_process || m_process->state() != QProcess::Running) { + return; + } + QByteArray data = QJsonDocument(message).toJson(QJsonDocument::Compact); + data.append('\n'); + m_process->write(data); +} + +void McpServer::handleLine(const QByteArray &line) +{ + const QJsonDocument document = QJsonDocument::fromJson(line); + if (!document.isObject()) { + return; + } + const QJsonObject object = document.object(); + + if (object.contains(QStringLiteral("id"))) { + const int id = object.value(QStringLiteral("id")).toInt(-1); + auto callback = m_callbacks.take(id); + if (callback) { + callback(object); + } + } +} + +void McpServer::callTool(const QString &tool, const QJsonObject &arguments, + std::function callback) +{ + if (!m_ready) { + QJsonObject error{ { QStringLiteral("error"), + QStringLiteral("MCP 服务器尚未就绪") } }; + if (callback) { + callback(error); + } + return; + } + + const int id = m_nextId++; + m_callbacks.insert(id, std::move(callback)); + QJsonObject params{ { QStringLiteral("name"), tool }, + { QStringLiteral("arguments"), arguments } }; + send(QJsonObject{ { QStringLiteral("jsonrpc"), QStringLiteral("2.0") }, + { QStringLiteral("id"), id }, + { QStringLiteral("method"), QStringLiteral("tools/call") }, + { QStringLiteral("params"), params } }); +} + +McpManager *McpManager::instance() +{ + static McpManager *self = new McpManager; + return self; +} + +McpManager::McpManager(QObject *parent) + : QObject(parent) +{ +} + +void McpManager::clearServers() +{ + for (McpServer *server : m_servers) { + server->deleteLater(); + } + m_servers.clear(); +} + +void McpManager::reload() +{ + clearServers(); + m_routes.clear(); + m_definitions = QJsonArray(); + m_status.clear(); + + const QString json = + Config::instance()->get(QStringLiteral("chat/mcpServers")).toString().trimmed(); + if (!json.isEmpty()) { + const QJsonDocument document = QJsonDocument::fromJson(json.toUtf8()); + const QJsonArray servers = document.isArray() ? document.array() : QJsonArray(); + for (const QJsonValue &value : servers) { + const QJsonObject object = value.toObject(); + const QString command = object.value(QStringLiteral("command")).toString(); + if (command.isEmpty()) { + continue; + } + QString name = object.value(QStringLiteral("name")).toString(); + if (name.isEmpty()) { + name = QStringLiteral("mcp"); + } + QStringList args; + for (const QJsonValue &arg : object.value(QStringLiteral("args")).toArray()) { + args.append(arg.toString()); + } + auto *server = new McpServer(name, command, args, this); + m_servers.append(server); + connect(server, &McpServer::readyChanged, this, [this]() { rebuild(); }); + server->start(); + } + } + + rebuild(); +} + +void McpManager::rebuild() +{ + m_definitions = QJsonArray(); + m_routes.clear(); + m_status.clear(); + + QSet used; + for (McpServer *server : m_servers) { + const QJsonArray tools = server->tools(); + m_status.append(QStringLiteral("%1: %2 个工具%3") + .arg(server->name()) + .arg(tools.size()) + .arg(server->ready() ? QString() : QStringLiteral("(连接中)"))); + for (const QJsonValue &value : tools) { + const QJsonObject tool = value.toObject(); + QString exposed = tool.value(QStringLiteral("name")).toString(); + exposed.replace(QRegularExpression(QStringLiteral("[^A-Za-z0-9_-]")), + QStringLiteral("_")); + if (exposed.isEmpty()) { + continue; + } + if (used.contains(exposed)) { + exposed = server->name() + QLatin1Char('_') + exposed; + exposed.replace(QRegularExpression(QStringLiteral("[^A-Za-z0-9_-]")), + QStringLiteral("_")); + } + while (used.contains(exposed)) { + exposed += QLatin1Char('_'); + } + used.insert(exposed); + + m_routes.insert(exposed, ToolRoute{ server, tool.value(QStringLiteral("name")).toString() }); + + QJsonObject function; + function.insert(QStringLiteral("name"), exposed); + function.insert(QStringLiteral("description"), + tool.value(QStringLiteral("description")).toString()); + QJsonObject parameters = tool.value(QStringLiteral("inputSchema")).toObject(); + if (parameters.isEmpty()) { + parameters.insert(QStringLiteral("type"), QStringLiteral("object")); + parameters.insert(QStringLiteral("properties"), QJsonObject{}); + } + function.insert(QStringLiteral("parameters"), parameters); + m_definitions.append( + QJsonObject{ { QStringLiteral("type"), QStringLiteral("function") }, + { QStringLiteral("function"), function } }); + } + } + + Q_EMIT toolsChanged(); +} + +bool McpManager::hasTools() const +{ + return !m_definitions.isEmpty(); +} + +QJsonArray McpManager::toolDefinitions() const +{ + return m_definitions; +} + +QStringList McpManager::statusLines() const +{ + return m_status; +} + +void McpManager::callTool(const QString &exposedName, const QJsonObject &arguments, + std::function callback) +{ + const auto it = m_routes.constFind(exposedName); + if (it == m_routes.constEnd()) { + if (callback) { + callback(QJsonObject{ { QStringLiteral("error"), + QStringLiteral("未找到工具 %1").arg(exposedName) } }); + } + return; + } + it->server->callTool(it->originalName, arguments, std::move(callback)); +} diff --git a/src/chat/mcpclient.h b/src/chat/mcpclient.h new file mode 100644 index 0000000..c6683fb --- /dev/null +++ b/src/chat/mcpclient.h @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#include + +class QProcess; + +/** + * One MCP server speaking JSON-RPC 2.0 over stdio. + */ +class McpServer : public QObject +{ + Q_OBJECT + +public: + McpServer(const QString &name, const QString &command, const QStringList &args, + QObject *parent = nullptr); + ~McpServer() override; + + void start(); + + QString name() const { return m_name; } + bool ready() const { return m_ready; } + QJsonArray tools() const { return m_tools; } + + void callTool(const QString &tool, const QJsonObject &arguments, + std::function callback); + +signals: + void readyChanged(); + +private: + void send(const QJsonObject &message); + void handleLine(const QByteArray &line); + void finishToolList(); + + QString m_name; + QString m_command; + QStringList m_args; + QProcess *m_process = nullptr; + QByteArray m_buffer; + int m_nextId = 1; + QHash> m_callbacks; + QJsonArray m_tools; + bool m_ready = false; +}; + +/** + * Aggregates the configured MCP servers and exposes their tools in the + * OpenAI function-calling format. + */ +class McpManager : public QObject +{ + Q_OBJECT + +public: + static McpManager *instance(); + + void reload(); + + bool hasTools() const; + QJsonArray toolDefinitions() const; + QStringList statusLines() const; + + void callTool(const QString &exposedName, const QJsonObject &arguments, + std::function callback); + +signals: + void toolsChanged(); + +private: + explicit McpManager(QObject *parent = nullptr); + void clearServers(); + void rebuild(); + + QList m_servers; + struct ToolRoute + { + McpServer *server = nullptr; + QString originalName; + }; + QHash m_routes; + QJsonArray m_definitions; + QStringList m_status; +}; diff --git a/tests/mcp/test_server.py b/tests/mcp/test_server.py new file mode 100644 index 0000000..7cd1f20 --- /dev/null +++ b/tests/mcp/test_server.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2026 GXDE OS Team +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tiny stdio MCP server used by tests/mcptest.cpp.""" + +import json +import sys + + +def reply(message, result): + print(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": result}), flush=True) + + +for line in sys.stdin: + line = line.strip() + if not line: + continue + message = json.loads(line) + method = message.get("method") + + if method == "initialize": + reply(message, { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "test-server", "version": "0.1"}, + }) + elif method == "tools/list": + reply(message, { + "tools": [{ + "name": "echo", + "description": "Echo the given text back", + "inputSchema": { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + }] + }) + elif method == "tools/call": + arguments = message.get("params", {}).get("arguments", {}) + reply(message, { + "content": [{"type": "text", "text": "echo: " + str(arguments.get("text", ""))}] + }) + elif "id" in message: + reply(message, {}) diff --git a/tests/mcptest.cpp b/tests/mcptest.cpp new file mode 100644 index 0000000..0bc0ea8 --- /dev/null +++ b/tests/mcptest.cpp @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 GXDE OS Team +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Headless MCP smoke test: starts the given command as an MCP server, waits for +// tools/list and calls the first tool once. + +#include +#include +#include +#include + +#include "chat/mcpclient.h" + +int main(int argc, char *argv[]) +{ + QCoreApplication app(argc, argv); + QTextStream out(stdout); + + if (argc < 3) { + out << "usage: " << argv[0] << "