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
+2 -1
View File
@@ -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 开机自启与保活开关;不支持环境只提示一次并保持待命
+9
View File
@@ -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)
+1 -1
View File
@@ -47,7 +47,7 @@
- 多对话管理、流式输出、Markdown、代码块。
- 编辑消息、重试、从任意消息 fork。
- 上下文长度设置 + 手动/自动压缩。
- MCP(stdio)工具接入。
- MCP(stdio)工具接入:设置里填服务器 JSON,工具自动以 function calling 暴露给模型。
- 独立桌面入口;后续作为 GXDE 各项能力的 AI 入口与 skill 平台。
---
+47 -4
View File
@@ -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
View File
@@ -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
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();
}
+4
View File
@@ -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;
};
+284
View File
@@ -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));
}
+93
View File
@@ -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;
};
+45
View File
@@ -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, {})
+60
View File
@@ -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();
}