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:
+2
-1
@@ -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 开机自启与保活开关;不支持环境只提示一次并保持待命
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
- 多对话管理、流式输出、Markdown、代码块。
|
||||
- 编辑消息、重试、从任意消息 fork。
|
||||
- 上下文长度设置 + 手动/自动压缩。
|
||||
- MCP(stdio)工具接入。
|
||||
- MCP(stdio)工具接入:设置里填服务器 JSON,工具自动以 function calling 暴露给模型。
|
||||
- 独立桌面入口;后续作为 GXDE 各项能力的 AI 入口与 skill 平台。
|
||||
|
||||
---
|
||||
|
||||
+47
-4
@@ -15,6 +15,7 @@
|
||||
namespace {
|
||||
|
||||
bool prepareRequest(const QVector<ChatMessage> &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<ChatMessage> &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<ChatMessage> &messages)
|
||||
void ChatClient::setTools(const QJsonArray &tools)
|
||||
{
|
||||
m_tools = tools;
|
||||
}
|
||||
|
||||
void ChatClient::send(const QVector<ChatMessage> &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<ChatMessage> &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<ToolCall> calls = m_toolCalls;
|
||||
m_toolCalls.clear();
|
||||
Q_EMIT toolCallsReady(calls);
|
||||
reply->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
Q_EMIT finished(m_fullText);
|
||||
reply->deleteLater();
|
||||
}
|
||||
|
||||
+13
-1
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
@@ -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<ChatMessage> &messages);
|
||||
void setTools(const QJsonArray &tools);
|
||||
void send(const QVector<ChatMessage> &messages, const QJsonArray &extraMessages = QJsonArray());
|
||||
void sendOnce(const QVector<ChatMessage> &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<ToolCall> &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<ToolCall> m_toolCalls;
|
||||
QString m_fullText;
|
||||
bool m_done = false;
|
||||
};
|
||||
|
||||
+123
-1
@@ -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();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QVector>
|
||||
#include <QWidget>
|
||||
|
||||
@@ -58,6 +59,7 @@ private:
|
||||
void updateSidebar();
|
||||
void sendMessage();
|
||||
void requestCompletion();
|
||||
void runToolCalls(const QVector<ToolCall> &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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "mcpclient.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonDocument>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
|
||||
#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<void(const QJsonObject &)> 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<QString> 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<void(const QJsonObject &)> 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));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// SPDX-FileCopyrightText: 2026 GXDE OS Team
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <QHash>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QObject>
|
||||
#include <QStringList>
|
||||
|
||||
#include <functional>
|
||||
|
||||
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<void(const QJsonObject &)> 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<int, std::function<void(const QJsonObject &)>> 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<void(const QJsonObject &)> callback);
|
||||
|
||||
signals:
|
||||
void toolsChanged();
|
||||
|
||||
private:
|
||||
explicit McpManager(QObject *parent = nullptr);
|
||||
void clearServers();
|
||||
void rebuild();
|
||||
|
||||
QList<McpServer *> m_servers;
|
||||
struct ToolRoute
|
||||
{
|
||||
McpServer *server = nullptr;
|
||||
QString originalName;
|
||||
};
|
||||
QHash<QString, ToolRoute> m_routes;
|
||||
QJsonArray m_definitions;
|
||||
QStringList m_status;
|
||||
};
|
||||
@@ -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, {})
|
||||
@@ -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 <QCoreApplication>
|
||||
#include <QJsonDocument>
|
||||
#include <QTextStream>
|
||||
#include <QTimer>
|
||||
|
||||
#include "chat/mcpclient.h"
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
QCoreApplication app(argc, argv);
|
||||
QTextStream out(stdout);
|
||||
|
||||
if (argc < 3) {
|
||||
out << "usage: " << argv[0] << " <command> <script> [args...]\n";
|
||||
return 2;
|
||||
}
|
||||
|
||||
QStringList args;
|
||||
for (int i = 2; i < argc; ++i) {
|
||||
args.append(QString::fromLocal8Bit(argv[i]));
|
||||
}
|
||||
|
||||
McpServer server(QStringLiteral("test"), QString::fromLocal8Bit(argv[1]), args);
|
||||
|
||||
QObject::connect(&server, &McpServer::readyChanged, [&]() {
|
||||
out << "READY tools=" << server.tools().size() << "\n";
|
||||
if (server.tools().isEmpty()) {
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
const QString name =
|
||||
server.tools().first().toObject().value(QStringLiteral("name")).toString();
|
||||
out << "TOOL " << name << "\n";
|
||||
server.callTool(name, QJsonObject{ { QStringLiteral("text"),
|
||||
QStringLiteral("hello") } },
|
||||
[&](const QJsonObject &result) {
|
||||
out << "RESULT "
|
||||
<< QString::fromUtf8(
|
||||
QJsonDocument(result).toJson(QJsonDocument::Compact))
|
||||
<< "\n";
|
||||
out.flush();
|
||||
app.quit();
|
||||
});
|
||||
});
|
||||
|
||||
server.start();
|
||||
QTimer::singleShot(8000, &app, [&]() {
|
||||
out << "TIMEOUT\n";
|
||||
out.flush();
|
||||
app.exit(1);
|
||||
});
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
Reference in New Issue
Block a user