mirror of
https://github.com/Palm1r/QodeAssist.git
synced 2026-05-30 02:49:12 -04:00
feat: Add mcp client hub
This commit is contained in:
@@ -158,6 +158,9 @@ add_qtc_plugin(QodeAssist
|
|||||||
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
|
tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp
|
||||||
tools/TodoTool.hpp tools/TodoTool.cpp
|
tools/TodoTool.hpp tools/TodoTool.cpp
|
||||||
mcp/McpServerManager.hpp mcp/McpServerManager.cpp
|
mcp/McpServerManager.hpp mcp/McpServerManager.cpp
|
||||||
|
mcp/McpServerConnection.hpp mcp/McpServerConnection.cpp
|
||||||
|
mcp/McpClientsManager.hpp mcp/McpClientsManager.cpp
|
||||||
|
settings/McpClientsListAspect.hpp settings/McpClientsListAspect.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
|
get_target_property(QtCreatorCorePath QtCreator::Core LOCATION)
|
||||||
|
|||||||
364
mcp/McpClientsManager.cpp
Normal file
364
mcp/McpClientsManager.cpp
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "McpClientsManager.hpp"
|
||||||
|
|
||||||
|
#include <coreplugin/icore.h>
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QFileSystemWatcher>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
#include <logger/Logger.hpp>
|
||||||
|
#include <pluginllmcore/Provider.hpp>
|
||||||
|
#include <pluginllmcore/ProvidersManager.hpp>
|
||||||
|
#include <settings/McpSettings.hpp>
|
||||||
|
|
||||||
|
namespace QodeAssist::Mcp {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr const char *kServersKey = "mcpServers";
|
||||||
|
|
||||||
|
QByteArray serializedEntry(const McpServerConfig &cfg)
|
||||||
|
{
|
||||||
|
return QJsonDocument(cfg.toJson()).toJson(QJsonDocument::Compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
McpClientsManager &McpClientsManager::instance()
|
||||||
|
{
|
||||||
|
static McpClientsManager manager;
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString McpClientsManager::configFilePath()
|
||||||
|
{
|
||||||
|
const QString base = Core::ICore::userResourcePath().toFSPathString();
|
||||||
|
return QDir(base).filePath(QStringLiteral("qodeassist/mcp-server.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray McpClientsManager::emptyConfigTemplate()
|
||||||
|
{
|
||||||
|
return QByteArray(
|
||||||
|
"{\n"
|
||||||
|
" \"mcpServers\": {\n"
|
||||||
|
" // Example HTTP/SSE server:\n"
|
||||||
|
" // \"my-http-server\": {\n"
|
||||||
|
" // \"type\": \"sse\",\n"
|
||||||
|
" // \"url\": \"http://127.0.0.1:9000/mcp\",\n"
|
||||||
|
" // \"enable\": true\n"
|
||||||
|
" // },\n"
|
||||||
|
" // Example stdio server:\n"
|
||||||
|
" // \"my-stdio-server\": {\n"
|
||||||
|
" // \"command\": \"/path/to/server\",\n"
|
||||||
|
" // \"args\": [\"--flag\"],\n"
|
||||||
|
" // \"env\": {\"KEY\": \"value\"},\n"
|
||||||
|
" // \"enable\": false\n"
|
||||||
|
" // }\n"
|
||||||
|
" }\n"
|
||||||
|
"}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
McpClientsManager::McpClientsManager(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{}
|
||||||
|
|
||||||
|
McpClientsManager::~McpClientsManager()
|
||||||
|
{
|
||||||
|
for (auto *c : m_connections) {
|
||||||
|
if (c)
|
||||||
|
c->disconnectFromServer();
|
||||||
|
}
|
||||||
|
m_connections.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpClientsManager::init()
|
||||||
|
{
|
||||||
|
if (m_initialized)
|
||||||
|
return;
|
||||||
|
m_initialized = true;
|
||||||
|
|
||||||
|
ensureFileExists();
|
||||||
|
setupWatcher();
|
||||||
|
|
||||||
|
connect(
|
||||||
|
&Settings::mcpSettings().enableMcpClients,
|
||||||
|
&Utils::BaseAspect::changed,
|
||||||
|
this,
|
||||||
|
[this]() { loadFromDisk(); });
|
||||||
|
|
||||||
|
connect(
|
||||||
|
&Settings::mcpSettings().mcpClientExtraPaths,
|
||||||
|
&Utils::BaseAspect::changed,
|
||||||
|
this,
|
||||||
|
[this]() {
|
||||||
|
for (auto *c : m_connections) {
|
||||||
|
if (c && c->config().transport == McpTransportKind::Stdio
|
||||||
|
&& c->config().enabled
|
||||||
|
&& Settings::mcpSettings().enableMcpClients()) {
|
||||||
|
c->disconnectFromServer();
|
||||||
|
c->connectToServer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
loadFromDisk();
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpClientsManager::ensureFileExists()
|
||||||
|
{
|
||||||
|
const QString path = configFilePath();
|
||||||
|
QFileInfo fi(path);
|
||||||
|
if (fi.exists())
|
||||||
|
return;
|
||||||
|
|
||||||
|
QDir().mkpath(fi.absolutePath());
|
||||||
|
QFile f(path);
|
||||||
|
if (f.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||||
|
f.write(emptyConfigTemplate());
|
||||||
|
f.close();
|
||||||
|
LOG_MESSAGE(QString("Created empty MCP clients config: %1").arg(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpClientsManager::setupWatcher()
|
||||||
|
{
|
||||||
|
m_watcher = new QFileSystemWatcher(this);
|
||||||
|
m_reloadDebounce = new QTimer(this);
|
||||||
|
m_reloadDebounce->setSingleShot(true);
|
||||||
|
m_reloadDebounce->setInterval(300);
|
||||||
|
|
||||||
|
connect(m_reloadDebounce.data(), &QTimer::timeout, this, [this]() {
|
||||||
|
const bool suppress = m_suppressNextWatcherReload;
|
||||||
|
m_suppressNextWatcherReload = false;
|
||||||
|
if (!suppress)
|
||||||
|
loadFromDisk();
|
||||||
|
updateWatchedPaths();
|
||||||
|
});
|
||||||
|
connect(m_watcher.data(), &QFileSystemWatcher::fileChanged, this, [this]() {
|
||||||
|
m_reloadDebounce->start();
|
||||||
|
});
|
||||||
|
connect(m_watcher.data(), &QFileSystemWatcher::directoryChanged, this, [this]() {
|
||||||
|
m_reloadDebounce->start();
|
||||||
|
});
|
||||||
|
|
||||||
|
updateWatchedPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpClientsManager::updateWatchedPaths()
|
||||||
|
{
|
||||||
|
if (!m_watcher)
|
||||||
|
return;
|
||||||
|
if (!m_watcher->files().isEmpty())
|
||||||
|
m_watcher->removePaths(m_watcher->files());
|
||||||
|
if (!m_watcher->directories().isEmpty())
|
||||||
|
m_watcher->removePaths(m_watcher->directories());
|
||||||
|
|
||||||
|
const QString path = configFilePath();
|
||||||
|
const QFileInfo info(path);
|
||||||
|
if (info.exists())
|
||||||
|
m_watcher->addPath(path);
|
||||||
|
const QString dir = info.absolutePath();
|
||||||
|
if (QFileInfo::exists(dir))
|
||||||
|
m_watcher->addPath(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<McpServerConnection *> McpClientsManager::connections() const
|
||||||
|
{
|
||||||
|
return m_connections;
|
||||||
|
}
|
||||||
|
|
||||||
|
QList<PluginLLMCore::Provider *> McpClientsManager::toolsCapableProviders() const
|
||||||
|
{
|
||||||
|
QList<PluginLLMCore::Provider *> out;
|
||||||
|
auto &pm = PluginLLMCore::ProvidersManager::instance();
|
||||||
|
for (const QString &name : pm.providersNames()) {
|
||||||
|
auto *p = pm.getProviderByName(name);
|
||||||
|
if (!p)
|
||||||
|
continue;
|
||||||
|
if (p->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools))
|
||||||
|
out.append(p);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject McpClientsManager::readRoot() const
|
||||||
|
{
|
||||||
|
QFile f(configFilePath());
|
||||||
|
if (!f.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||||
|
return QJsonObject{{QLatin1String(kServersKey), QJsonObject{}}};
|
||||||
|
QJsonParseError err;
|
||||||
|
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err);
|
||||||
|
f.close();
|
||||||
|
if (err.error != QJsonParseError::NoError || !doc.isObject())
|
||||||
|
return QJsonObject{{QLatin1String(kServersKey), QJsonObject{}}};
|
||||||
|
QJsonObject root = doc.object();
|
||||||
|
if (!root.contains(QLatin1String(kServersKey)))
|
||||||
|
root.insert(QLatin1String(kServersKey), QJsonObject{});
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool McpClientsManager::writeRoot(const QJsonObject &root)
|
||||||
|
{
|
||||||
|
QSaveFile out(configFilePath());
|
||||||
|
if (!out.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||||
|
const QString reason = out.errorString();
|
||||||
|
LOG_MESSAGE(
|
||||||
|
QString("MCP clients: cannot write %1: %2").arg(configFilePath(), reason));
|
||||||
|
emit writeFailed(reason);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||||
|
if (!out.commit()) {
|
||||||
|
const QString reason = out.errorString();
|
||||||
|
LOG_MESSAGE(
|
||||||
|
QString("MCP clients: commit failed for %1: %2").arg(configFilePath(), reason));
|
||||||
|
emit writeFailed(reason);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_suppressNextWatcherReload = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpClientsManager::reload()
|
||||||
|
{
|
||||||
|
loadFromDisk();
|
||||||
|
updateWatchedPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool McpClientsManager::setServerEnabled(const QString &name, bool enabled)
|
||||||
|
{
|
||||||
|
QJsonObject root = readRoot();
|
||||||
|
QJsonObject servers = root.value(QLatin1String(kServersKey)).toObject();
|
||||||
|
if (!servers.contains(name)) {
|
||||||
|
LOG_MESSAGE(QString("MCP clients: setServerEnabled: no entry '%1'").arg(name));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QJsonObject entry = servers.value(name).toObject();
|
||||||
|
entry.insert(QStringLiteral("enable"), enabled);
|
||||||
|
servers.insert(name, entry);
|
||||||
|
root.insert(QLatin1String(kServersKey), servers);
|
||||||
|
if (!writeRoot(root))
|
||||||
|
return false;
|
||||||
|
loadFromDisk();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool McpClientsManager::addServer(const QString &name, const QJsonObject &entry)
|
||||||
|
{
|
||||||
|
QJsonObject root = readRoot();
|
||||||
|
QJsonObject servers = root.value(QLatin1String(kServersKey)).toObject();
|
||||||
|
|
||||||
|
QString finalName = name;
|
||||||
|
int suffix = 2;
|
||||||
|
while (servers.contains(finalName))
|
||||||
|
finalName = QStringLiteral("%1-%2").arg(name).arg(suffix++);
|
||||||
|
|
||||||
|
servers.insert(finalName, entry);
|
||||||
|
root.insert(QLatin1String(kServersKey), servers);
|
||||||
|
if (!writeRoot(root))
|
||||||
|
return false;
|
||||||
|
loadFromDisk();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool McpClientsManager::removeServer(const QString &name)
|
||||||
|
{
|
||||||
|
QJsonObject root = readRoot();
|
||||||
|
QJsonObject servers = root.value(QLatin1String(kServersKey)).toObject();
|
||||||
|
if (!servers.contains(name))
|
||||||
|
return false;
|
||||||
|
servers.remove(name);
|
||||||
|
root.insert(QLatin1String(kServersKey), servers);
|
||||||
|
if (!writeRoot(root))
|
||||||
|
return false;
|
||||||
|
loadFromDisk();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpClientsManager::loadFromDisk()
|
||||||
|
{
|
||||||
|
const QJsonObject root = readRoot();
|
||||||
|
const QJsonObject servers = root.value(QLatin1String(kServersKey)).toObject();
|
||||||
|
|
||||||
|
QList<McpServerConfig> newConfigs;
|
||||||
|
for (auto it = servers.begin(); it != servers.end(); ++it) {
|
||||||
|
if (!it.value().isObject())
|
||||||
|
continue;
|
||||||
|
newConfigs.append(McpServerConfig::fromJson(it.key(), it.value().toObject()));
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto providers = toolsCapableProviders();
|
||||||
|
|
||||||
|
const bool masterEnabled = Settings::mcpSettings().enableMcpClients();
|
||||||
|
|
||||||
|
QList<McpServerConnection *> keep;
|
||||||
|
QList<McpServerConnection *> oldConnections = m_connections;
|
||||||
|
m_connections.clear();
|
||||||
|
|
||||||
|
for (const McpServerConfig &cfg : newConfigs) {
|
||||||
|
McpServerConnection *existing = nullptr;
|
||||||
|
for (auto *c : oldConnections) {
|
||||||
|
if (c && c->config().name == cfg.name) {
|
||||||
|
existing = c;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool configUnchanged
|
||||||
|
= existing && serializedEntry(existing->config()) == serializedEntry(cfg);
|
||||||
|
|
||||||
|
McpServerConnection *c = nullptr;
|
||||||
|
if (configUnchanged) {
|
||||||
|
oldConnections.removeAll(existing);
|
||||||
|
c = existing;
|
||||||
|
} else {
|
||||||
|
if (existing) {
|
||||||
|
oldConnections.removeAll(existing);
|
||||||
|
existing->disconnectFromServer();
|
||||||
|
existing->deleteLater();
|
||||||
|
}
|
||||||
|
c = new McpServerConnection(cfg, this);
|
||||||
|
c->setProviders(providers);
|
||||||
|
connect(
|
||||||
|
c,
|
||||||
|
&McpServerConnection::stateChanged,
|
||||||
|
this,
|
||||||
|
&McpClientsManager::serversChanged);
|
||||||
|
}
|
||||||
|
keep.append(c);
|
||||||
|
|
||||||
|
const bool wantRunning = cfg.enabled && masterEnabled;
|
||||||
|
const bool currentlyRunning = c->state() == McpConnectionState::Connected
|
||||||
|
|| c->state() == McpConnectionState::Connecting;
|
||||||
|
|
||||||
|
if (wantRunning && !currentlyRunning)
|
||||||
|
c->connectToServer();
|
||||||
|
else if (!wantRunning && currentlyRunning)
|
||||||
|
c->disconnectFromServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto *c : oldConnections) {
|
||||||
|
if (!c)
|
||||||
|
continue;
|
||||||
|
c->disconnectFromServer();
|
||||||
|
c->deleteLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
m_connections = keep;
|
||||||
|
|
||||||
|
LOG_MESSAGE(QString("MCP clients: loaded %1 server(s) from %2")
|
||||||
|
.arg(m_connections.size())
|
||||||
|
.arg(configFilePath()));
|
||||||
|
|
||||||
|
emit serversChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Mcp
|
||||||
63
mcp/McpClientsManager.hpp
Normal file
63
mcp/McpClientsManager.hpp
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QList>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "McpServerConnection.hpp"
|
||||||
|
|
||||||
|
class QFileSystemWatcher;
|
||||||
|
class QTimer;
|
||||||
|
|
||||||
|
namespace QodeAssist::Mcp {
|
||||||
|
|
||||||
|
class McpClientsManager : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
static McpClientsManager &instance();
|
||||||
|
|
||||||
|
static QString configFilePath();
|
||||||
|
static QByteArray emptyConfigTemplate();
|
||||||
|
|
||||||
|
void init();
|
||||||
|
|
||||||
|
QList<McpServerConnection *> connections() const;
|
||||||
|
|
||||||
|
bool setServerEnabled(const QString &name, bool enabled);
|
||||||
|
bool addServer(const QString &name, const QJsonObject &entry);
|
||||||
|
bool removeServer(const QString &name);
|
||||||
|
void reload();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void serversChanged();
|
||||||
|
void writeFailed(const QString &reason);
|
||||||
|
|
||||||
|
private:
|
||||||
|
explicit McpClientsManager(QObject *parent = nullptr);
|
||||||
|
~McpClientsManager() override;
|
||||||
|
McpClientsManager(const McpClientsManager &) = delete;
|
||||||
|
McpClientsManager &operator=(const McpClientsManager &) = delete;
|
||||||
|
|
||||||
|
void loadFromDisk();
|
||||||
|
void ensureFileExists();
|
||||||
|
void setupWatcher();
|
||||||
|
void updateWatchedPaths();
|
||||||
|
|
||||||
|
QList<PluginLLMCore::Provider *> toolsCapableProviders() const;
|
||||||
|
QJsonObject readRoot() const;
|
||||||
|
bool writeRoot(const QJsonObject &root);
|
||||||
|
|
||||||
|
bool m_initialized = false;
|
||||||
|
bool m_suppressNextWatcherReload = false;
|
||||||
|
QList<McpServerConnection *> m_connections;
|
||||||
|
QPointer<QFileSystemWatcher> m_watcher;
|
||||||
|
QPointer<QTimer> m_reloadDebounce;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Mcp
|
||||||
391
mcp/McpServerConnection.cpp
Normal file
391
mcp/McpServerConnection.cpp
Normal file
@@ -0,0 +1,391 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "McpServerConnection.hpp"
|
||||||
|
|
||||||
|
#include <LLMQore/McpClient.hpp>
|
||||||
|
#include <LLMQore/McpExceptions.hpp>
|
||||||
|
#include <LLMQore/McpHttpTransport.hpp>
|
||||||
|
#include <LLMQore/McpRemoteTool.hpp>
|
||||||
|
#include <LLMQore/McpStdioTransport.hpp>
|
||||||
|
#include <LLMQore/McpTransport.hpp>
|
||||||
|
#include <LLMQore/McpTypes.hpp>
|
||||||
|
#include <LLMQore/ToolsManager.hpp>
|
||||||
|
#include <LLMQore/Version.hpp>
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QFuture>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
|
||||||
|
#include <logger/Logger.hpp>
|
||||||
|
#include <pluginllmcore/Provider.hpp>
|
||||||
|
#include <settings/McpSettings.hpp>
|
||||||
|
|
||||||
|
namespace QodeAssist::Mcp {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString transportToString(McpTransportKind k)
|
||||||
|
{
|
||||||
|
return k == McpTransportKind::Http ? QStringLiteral("http") : QStringLiteral("stdio");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool providerSupportsTools(PluginLLMCore::Provider *p)
|
||||||
|
{
|
||||||
|
if (!p)
|
||||||
|
return false;
|
||||||
|
return p->capabilities().testFlag(PluginLLMCore::ProviderCapability::Tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
McpServerConfig McpServerConfig::fromJson(const QString &name, const QJsonObject &obj)
|
||||||
|
{
|
||||||
|
McpServerConfig cfg;
|
||||||
|
cfg.name = name;
|
||||||
|
// Accept both "enable" (preferred) and "enabled" (legacy).
|
||||||
|
if (obj.contains(QStringLiteral("enable")))
|
||||||
|
cfg.enabled = obj.value(QStringLiteral("enable")).toBool(true);
|
||||||
|
else
|
||||||
|
cfg.enabled = obj.value(QStringLiteral("enabled")).toBool(true);
|
||||||
|
|
||||||
|
const QString type = obj.value(QStringLiteral("type")).toString().toLower();
|
||||||
|
const QString transport = obj.value(QStringLiteral("transport")).toString().toLower();
|
||||||
|
const bool hasCommand = obj.contains(QStringLiteral("command"));
|
||||||
|
const bool hasUrl = obj.contains(QStringLiteral("url"));
|
||||||
|
|
||||||
|
if (transport == QStringLiteral("stdio") || type == QStringLiteral("stdio")
|
||||||
|
|| (hasCommand && !hasUrl)) {
|
||||||
|
cfg.transport = McpTransportKind::Stdio;
|
||||||
|
} else {
|
||||||
|
cfg.transport = McpTransportKind::Http;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cfg.transport == McpTransportKind::Http) {
|
||||||
|
cfg.url = QUrl(obj.value(QStringLiteral("url")).toString());
|
||||||
|
cfg.spec = obj.value(QStringLiteral("spec")).toString();
|
||||||
|
const QJsonObject headers = obj.value(QStringLiteral("headers")).toObject();
|
||||||
|
for (auto it = headers.begin(); it != headers.end(); ++it)
|
||||||
|
cfg.headers.insert(it.key(), it.value().toString());
|
||||||
|
} else {
|
||||||
|
cfg.command = obj.value(QStringLiteral("command")).toString();
|
||||||
|
const QJsonArray args = obj.value(QStringLiteral("args")).toArray();
|
||||||
|
for (const QJsonValue &v : args)
|
||||||
|
cfg.args.append(v.toString());
|
||||||
|
const QJsonObject env = obj.value(QStringLiteral("env")).toObject();
|
||||||
|
for (auto it = env.begin(); it != env.end(); ++it)
|
||||||
|
cfg.env.insert(it.key(), it.value().toString());
|
||||||
|
cfg.workingDirectory = obj.value(QStringLiteral("cwd")).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject McpServerConfig::toJson() const
|
||||||
|
{
|
||||||
|
QJsonObject obj;
|
||||||
|
obj[QStringLiteral("enable")] = enabled;
|
||||||
|
|
||||||
|
if (transport == McpTransportKind::Http) {
|
||||||
|
obj[QStringLiteral("type")] = QStringLiteral("sse");
|
||||||
|
obj[QStringLiteral("url")] = url.toString();
|
||||||
|
if (!spec.isEmpty())
|
||||||
|
obj[QStringLiteral("spec")] = spec;
|
||||||
|
if (!headers.isEmpty()) {
|
||||||
|
QJsonObject h;
|
||||||
|
for (auto it = headers.begin(); it != headers.end(); ++it)
|
||||||
|
h[it.key()] = it.value();
|
||||||
|
obj[QStringLiteral("headers")] = h;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
obj[QStringLiteral("type")] = QStringLiteral("stdio");
|
||||||
|
obj[QStringLiteral("command")] = command;
|
||||||
|
if (!args.isEmpty()) {
|
||||||
|
QJsonArray a;
|
||||||
|
for (const QString &s : args)
|
||||||
|
a.append(s);
|
||||||
|
obj[QStringLiteral("args")] = a;
|
||||||
|
}
|
||||||
|
if (!env.isEmpty()) {
|
||||||
|
QJsonObject e;
|
||||||
|
for (auto it = env.begin(); it != env.end(); ++it)
|
||||||
|
e[it.key()] = it.value();
|
||||||
|
obj[QStringLiteral("env")] = e;
|
||||||
|
}
|
||||||
|
if (!workingDirectory.isEmpty())
|
||||||
|
obj[QStringLiteral("cwd")] = workingDirectory;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
McpServerConnection::McpServerConnection(McpServerConfig config, QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_config(std::move(config))
|
||||||
|
{}
|
||||||
|
|
||||||
|
McpServerConnection::~McpServerConnection()
|
||||||
|
{
|
||||||
|
disconnectFromServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpServerConnection::setProviders(const QList<PluginLLMCore::Provider *> &providers)
|
||||||
|
{
|
||||||
|
m_providers.clear();
|
||||||
|
for (auto *p : providers) {
|
||||||
|
if (providerSupportsTools(p))
|
||||||
|
m_providers.append(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::LLMQore::Mcp::McpTransport *McpServerConnection::createTransport()
|
||||||
|
{
|
||||||
|
if (m_config.transport == McpTransportKind::Http) {
|
||||||
|
if (!m_config.url.isValid()) {
|
||||||
|
setState(
|
||||||
|
McpConnectionState::Failed,
|
||||||
|
QStringLiteral("Invalid URL: %1").arg(m_config.url.toString()));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
::LLMQore::Mcp::HttpTransportConfig cfg;
|
||||||
|
cfg.endpoint = m_config.url;
|
||||||
|
cfg.headers = m_config.headers;
|
||||||
|
if (m_config.spec == QLatin1String("2024-11-05"))
|
||||||
|
cfg.spec = ::LLMQore::Mcp::McpHttpSpec::V2024_11_05;
|
||||||
|
else if (m_config.spec == QLatin1String("2025-03-26"))
|
||||||
|
cfg.spec = ::LLMQore::Mcp::McpHttpSpec::V2025_03_26;
|
||||||
|
return new ::LLMQore::Mcp::McpHttpTransport(cfg, nullptr, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_config.command.isEmpty()) {
|
||||||
|
setState(McpConnectionState::Failed, QStringLiteral("Empty command"));
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
::LLMQore::Mcp::StdioLaunchConfig cfg;
|
||||||
|
cfg.arguments = m_config.args;
|
||||||
|
cfg.workingDirectory = m_config.workingDirectory;
|
||||||
|
|
||||||
|
QStringList extraDirs;
|
||||||
|
const QString extraPaths
|
||||||
|
= Settings::mcpSettings().mcpClientExtraPaths.volatileValue().trimmed();
|
||||||
|
if (!extraPaths.isEmpty()) {
|
||||||
|
const QChar sep = QDir::listSeparator();
|
||||||
|
for (const QString &p : extraPaths.split(sep, Qt::SkipEmptyParts)) {
|
||||||
|
const QString t = p.trimmed();
|
||||||
|
if (!t.isEmpty())
|
||||||
|
extraDirs << t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QProcess looks up a bare program name against the *parent* PATH before
|
||||||
|
// the custom environment below is applied to the child. Resolve the
|
||||||
|
// absolute path ourselves using the augmented search dirs so the user's
|
||||||
|
// "Extra PATH" field actually matters.
|
||||||
|
QString resolvedProgram = m_config.command;
|
||||||
|
const bool isBareName = !resolvedProgram.isEmpty()
|
||||||
|
&& !resolvedProgram.contains(QLatin1Char('/'))
|
||||||
|
&& !resolvedProgram.contains(QLatin1Char('\\'));
|
||||||
|
if (isBareName) {
|
||||||
|
const QString found = QStandardPaths::findExecutable(resolvedProgram, extraDirs);
|
||||||
|
if (!found.isEmpty())
|
||||||
|
resolvedProgram = found;
|
||||||
|
}
|
||||||
|
cfg.program = resolvedProgram;
|
||||||
|
|
||||||
|
QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
|
||||||
|
if (!extraDirs.isEmpty()) {
|
||||||
|
const QChar sep = QDir::listSeparator();
|
||||||
|
const QString existing = env.value(QStringLiteral("PATH"));
|
||||||
|
const QString merged = existing.isEmpty()
|
||||||
|
? extraDirs.join(sep)
|
||||||
|
: extraDirs.join(sep) + sep + existing;
|
||||||
|
env.insert(QStringLiteral("PATH"), merged);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto it = m_config.env.begin(); it != m_config.env.end(); ++it)
|
||||||
|
env.insert(it.key(), it.value());
|
||||||
|
cfg.environment = env;
|
||||||
|
|
||||||
|
return new ::LLMQore::Mcp::McpStdioClientTransport(cfg, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpServerConnection::connectToServer()
|
||||||
|
{
|
||||||
|
if (m_state == McpConnectionState::Connecting || m_state == McpConnectionState::Connected)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setState(McpConnectionState::Connecting, QStringLiteral("Connecting..."));
|
||||||
|
|
||||||
|
m_transport = createTransport();
|
||||||
|
if (!m_transport)
|
||||||
|
return;
|
||||||
|
|
||||||
|
::LLMQore::Mcp::Implementation clientInfo;
|
||||||
|
clientInfo.name = QStringLiteral("QodeAssist");
|
||||||
|
clientInfo.version = QStringLiteral(LLMQORE_VERSION_STRING);
|
||||||
|
|
||||||
|
m_client = new ::LLMQore::Mcp::McpClient(m_transport.data(), clientInfo, this);
|
||||||
|
|
||||||
|
connect(m_client.data(), &::LLMQore::Mcp::McpClient::toolsChanged, this, [this]() {
|
||||||
|
unregisterTools();
|
||||||
|
fetchAndRegisterTools();
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(m_client.data(), &::LLMQore::Mcp::McpClient::disconnected, this, [this]() {
|
||||||
|
setState(McpConnectionState::Failed, QStringLiteral("Disconnected"));
|
||||||
|
unregisterTools();
|
||||||
|
});
|
||||||
|
|
||||||
|
connect(
|
||||||
|
m_client.data(),
|
||||||
|
&::LLMQore::Mcp::McpClient::errorOccurred,
|
||||||
|
this,
|
||||||
|
[this](const QString &error) {
|
||||||
|
LOG_MESSAGE(
|
||||||
|
QString("MCP client [%1] error: %2").arg(m_config.name, error));
|
||||||
|
});
|
||||||
|
|
||||||
|
m_client->connectAndInitialize(std::chrono::seconds(15))
|
||||||
|
.then(this,
|
||||||
|
[this](const ::LLMQore::Mcp::InitializeResult &result) {
|
||||||
|
LOG_MESSAGE(QString("MCP client [%1] connected to %2 %3")
|
||||||
|
.arg(m_config.name,
|
||||||
|
result.serverInfo.name,
|
||||||
|
result.serverInfo.version));
|
||||||
|
fetchAndRegisterTools();
|
||||||
|
})
|
||||||
|
.onFailed(this, [this](const std::exception &e) {
|
||||||
|
const QString msg = QString::fromUtf8(e.what());
|
||||||
|
LOG_MESSAGE(QString("MCP client [%1] init failed: %2").arg(m_config.name, msg));
|
||||||
|
setState(McpConnectionState::Failed, msg);
|
||||||
|
disconnectFromServer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpServerConnection::fetchAndRegisterTools()
|
||||||
|
{
|
||||||
|
if (!m_client)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (m_listToolsWatchdog) {
|
||||||
|
m_listToolsWatchdog->stop();
|
||||||
|
m_listToolsWatchdog->deleteLater();
|
||||||
|
m_listToolsWatchdog.clear();
|
||||||
|
}
|
||||||
|
m_listToolsWatchdog = new QTimer(this);
|
||||||
|
m_listToolsWatchdog->setSingleShot(true);
|
||||||
|
connect(m_listToolsWatchdog.data(), &QTimer::timeout, this, [this]() {
|
||||||
|
if (m_state == McpConnectionState::Connected)
|
||||||
|
return;
|
||||||
|
LOG_MESSAGE(QString("MCP client [%1] listTools timed out").arg(m_config.name));
|
||||||
|
setState(McpConnectionState::Failed, QStringLiteral("listTools timed out"));
|
||||||
|
disconnectFromServer();
|
||||||
|
});
|
||||||
|
m_listToolsWatchdog->start(std::chrono::seconds(15));
|
||||||
|
|
||||||
|
m_client->listTools()
|
||||||
|
.then(this,
|
||||||
|
[this](const QList<::LLMQore::Mcp::ToolInfo> &tools) {
|
||||||
|
if (m_listToolsWatchdog)
|
||||||
|
m_listToolsWatchdog->stop();
|
||||||
|
if (m_providers.isEmpty()) {
|
||||||
|
LOG_MESSAGE(QString("MCP client [%1]: no tools-capable providers to "
|
||||||
|
"register %2 tools into")
|
||||||
|
.arg(m_config.name)
|
||||||
|
.arg(tools.size()));
|
||||||
|
setState(
|
||||||
|
McpConnectionState::Connected,
|
||||||
|
QStringLiteral("Connected (%1 tools)").arg(tools.size()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto &info : tools) {
|
||||||
|
if (info.name.isEmpty())
|
||||||
|
continue;
|
||||||
|
m_toolIds.append(info.name);
|
||||||
|
for (const auto &p : m_providers) {
|
||||||
|
if (!p)
|
||||||
|
continue;
|
||||||
|
auto *tm = p->toolsManager();
|
||||||
|
if (!tm)
|
||||||
|
continue;
|
||||||
|
auto *remote = new ::LLMQore::Mcp::McpRemoteTool(
|
||||||
|
m_client.data(), info, tm);
|
||||||
|
tm->addTool(remote);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_MESSAGE(QString("MCP client [%1]: registered %2 tools across %3 providers")
|
||||||
|
.arg(m_config.name)
|
||||||
|
.arg(tools.size())
|
||||||
|
.arg(m_providers.size()));
|
||||||
|
setState(
|
||||||
|
McpConnectionState::Connected,
|
||||||
|
QStringLiteral("Connected (%1 tools)").arg(tools.size()));
|
||||||
|
})
|
||||||
|
.onFailed(this, [this](const std::exception &e) {
|
||||||
|
if (m_listToolsWatchdog)
|
||||||
|
m_listToolsWatchdog->stop();
|
||||||
|
const QString msg = QString::fromUtf8(e.what());
|
||||||
|
LOG_MESSAGE(QString("MCP client [%1] listTools failed: %2").arg(m_config.name, msg));
|
||||||
|
setState(McpConnectionState::Failed, msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpServerConnection::unregisterTools()
|
||||||
|
{
|
||||||
|
if (m_toolIds.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (const auto &p : m_providers) {
|
||||||
|
if (!p)
|
||||||
|
continue;
|
||||||
|
auto *tm = p->toolsManager();
|
||||||
|
if (!tm)
|
||||||
|
continue;
|
||||||
|
for (const QString &id : m_toolIds)
|
||||||
|
tm->removeTool(id);
|
||||||
|
}
|
||||||
|
m_toolIds.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpServerConnection::disconnectFromServer()
|
||||||
|
{
|
||||||
|
if (m_listToolsWatchdog) {
|
||||||
|
m_listToolsWatchdog->stop();
|
||||||
|
m_listToolsWatchdog->deleteLater();
|
||||||
|
m_listToolsWatchdog.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterTools();
|
||||||
|
|
||||||
|
if (m_client) {
|
||||||
|
m_client->shutdown();
|
||||||
|
m_client->deleteLater();
|
||||||
|
m_client.clear();
|
||||||
|
}
|
||||||
|
if (m_transport) {
|
||||||
|
m_transport->deleteLater();
|
||||||
|
m_transport.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_state != McpConnectionState::Failed)
|
||||||
|
setState(McpConnectionState::Disabled, QStringLiteral("Disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
void McpServerConnection::setState(McpConnectionState state, const QString &text)
|
||||||
|
{
|
||||||
|
if (m_state == state && m_statusText == text)
|
||||||
|
return;
|
||||||
|
m_state = state;
|
||||||
|
m_statusText = text;
|
||||||
|
LOG_MESSAGE(QString("MCP client [%1] state: %2 [%3]")
|
||||||
|
.arg(m_config.name, text, transportToString(m_config.transport)));
|
||||||
|
emit stateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Mcp
|
||||||
93
mcp/McpServerConnection.hpp
Normal file
93
mcp/McpServerConnection.hpp
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QProcessEnvironment>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
|
||||||
|
namespace LLMQore::Mcp {
|
||||||
|
class McpClient;
|
||||||
|
class McpTransport;
|
||||||
|
} // namespace LLMQore::Mcp
|
||||||
|
|
||||||
|
namespace QodeAssist::PluginLLMCore {
|
||||||
|
class Provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace QodeAssist::Mcp {
|
||||||
|
|
||||||
|
enum class McpTransportKind { Http, Stdio };
|
||||||
|
|
||||||
|
struct McpServerConfig
|
||||||
|
{
|
||||||
|
QString name;
|
||||||
|
bool enabled = true;
|
||||||
|
|
||||||
|
McpTransportKind transport = McpTransportKind::Http;
|
||||||
|
|
||||||
|
// HTTP/SSE transport
|
||||||
|
QUrl url;
|
||||||
|
QHash<QString, QString> headers;
|
||||||
|
QString spec; // MCP protocol spec, e.g. "2024-11-05"; empty = Latest.
|
||||||
|
|
||||||
|
// Stdio transport
|
||||||
|
QString command;
|
||||||
|
QStringList args;
|
||||||
|
QHash<QString, QString> env;
|
||||||
|
QString workingDirectory;
|
||||||
|
|
||||||
|
static McpServerConfig fromJson(const QString &name, const QJsonObject &obj);
|
||||||
|
QJsonObject toJson() const;
|
||||||
|
};
|
||||||
|
|
||||||
|
enum class McpConnectionState { Disabled, Connecting, Connected, Failed };
|
||||||
|
|
||||||
|
class McpServerConnection : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit McpServerConnection(McpServerConfig config, QObject *parent = nullptr);
|
||||||
|
~McpServerConnection() override;
|
||||||
|
|
||||||
|
const McpServerConfig &config() const { return m_config; }
|
||||||
|
McpConnectionState state() const { return m_state; }
|
||||||
|
QString statusText() const { return m_statusText; }
|
||||||
|
int toolCount() const { return m_toolIds.size(); }
|
||||||
|
QStringList toolNames() const { return m_toolIds; }
|
||||||
|
|
||||||
|
void setProviders(const QList<PluginLLMCore::Provider *> &providers);
|
||||||
|
|
||||||
|
void connectToServer();
|
||||||
|
void disconnectFromServer();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void stateChanged();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void setState(McpConnectionState state, const QString &text = {});
|
||||||
|
void fetchAndRegisterTools();
|
||||||
|
void registerTools(const QList<::LLMQore::Mcp::McpClient *> & /*unused*/);
|
||||||
|
void unregisterTools();
|
||||||
|
::LLMQore::Mcp::McpTransport *createTransport();
|
||||||
|
|
||||||
|
McpServerConfig m_config;
|
||||||
|
McpConnectionState m_state = McpConnectionState::Disabled;
|
||||||
|
QString m_statusText;
|
||||||
|
|
||||||
|
QPointer<::LLMQore::Mcp::McpClient> m_client;
|
||||||
|
QPointer<::LLMQore::Mcp::McpTransport> m_transport;
|
||||||
|
QPointer<QTimer> m_listToolsWatchdog;
|
||||||
|
|
||||||
|
QList<QPointer<PluginLLMCore::Provider>> m_providers;
|
||||||
|
QStringList m_toolIds;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Mcp
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
#include "pluginllmcore/PromptProviderFim.hpp"
|
#include "pluginllmcore/PromptProviderFim.hpp"
|
||||||
#include "pluginllmcore/ProvidersManager.hpp"
|
#include "pluginllmcore/ProvidersManager.hpp"
|
||||||
#include "logger/RequestPerformanceLogger.hpp"
|
#include "logger/RequestPerformanceLogger.hpp"
|
||||||
|
#include "mcp/McpClientsManager.hpp"
|
||||||
#include "mcp/McpServerManager.hpp"
|
#include "mcp/McpServerManager.hpp"
|
||||||
#include "providers/Providers.hpp"
|
#include "providers/Providers.hpp"
|
||||||
#include "settings/ChatAssistantSettings.hpp"
|
#include "settings/ChatAssistantSettings.hpp"
|
||||||
@@ -167,6 +168,8 @@ public:
|
|||||||
m_mcpServerManager = new Mcp::McpServerManager(this);
|
m_mcpServerManager = new Mcp::McpServerManager(this);
|
||||||
m_mcpServerManager->init();
|
m_mcpServerManager->init();
|
||||||
|
|
||||||
|
Mcp::McpClientsManager::instance().init();
|
||||||
|
|
||||||
if (Settings::generalSettings().enableCheckUpdate()) {
|
if (Settings::generalSettings().enableCheckUpdate()) {
|
||||||
QTimer::singleShot(3000, this, &QodeAssistPlugin::checkForUpdates);
|
QTimer::singleShot(3000, this, &QodeAssistPlugin::checkForUpdates);
|
||||||
}
|
}
|
||||||
|
|||||||
503
settings/McpClientsListAspect.cpp
Normal file
503
settings/McpClientsListAspect.cpp
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#include "McpClientsListAspect.hpp"
|
||||||
|
|
||||||
|
#include <coreplugin/editormanager/editormanager.h>
|
||||||
|
#include <utils/filepath.h>
|
||||||
|
#include <utils/theme/theme.h>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "McpSettings.hpp"
|
||||||
|
#include "SettingsTr.hpp"
|
||||||
|
#include "StatusDot.hpp"
|
||||||
|
#include "mcp/McpClientsManager.hpp"
|
||||||
|
#include "mcp/McpServerConnection.hpp"
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
QString mutedColorHex()
|
||||||
|
{
|
||||||
|
if (auto *t = Utils::creatorTheme())
|
||||||
|
return t->color(Utils::Theme::TextColorDisabled).name();
|
||||||
|
return qApp->palette().color(QPalette::PlaceholderText).name();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString errorColorHex()
|
||||||
|
{
|
||||||
|
if (auto *t = Utils::creatorTheme())
|
||||||
|
return t->color(Utils::Theme::TextColorError).name();
|
||||||
|
return QStringLiteral("#d8444d");
|
||||||
|
}
|
||||||
|
|
||||||
|
QColor dotColorFor(Mcp::McpConnectionState state)
|
||||||
|
{
|
||||||
|
auto *t = Utils::creatorTheme();
|
||||||
|
switch (state) {
|
||||||
|
case Mcp::McpConnectionState::Connected:
|
||||||
|
return t ? t->color(Utils::Theme::IconsRunColor) : QColor(0x3fb950);
|
||||||
|
case Mcp::McpConnectionState::Failed:
|
||||||
|
return t ? t->color(Utils::Theme::TextColorError) : QColor(0xd8444d);
|
||||||
|
case Mcp::McpConnectionState::Connecting:
|
||||||
|
return t ? t->color(Utils::Theme::IconsWarningColor) : QColor(0xd29922);
|
||||||
|
case Mcp::McpConnectionState::Disabled:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return t ? t->color(Utils::Theme::TextColorDisabled) : QColor(0x888888);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString statusTooltip(Mcp::McpConnectionState state, const QString &detail)
|
||||||
|
{
|
||||||
|
switch (state) {
|
||||||
|
case Mcp::McpConnectionState::Connected:
|
||||||
|
return detail.isEmpty() ? McpClientsListAspect::tr("Connected.") : detail;
|
||||||
|
case Mcp::McpConnectionState::Connecting:
|
||||||
|
return McpClientsListAspect::tr("Connecting…");
|
||||||
|
case Mcp::McpConnectionState::Failed:
|
||||||
|
return detail.isEmpty() ? McpClientsListAspect::tr("Failed.")
|
||||||
|
: McpClientsListAspect::tr("Failed: %1").arg(detail);
|
||||||
|
case Mcp::McpConnectionState::Disabled:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return McpClientsListAspect::tr("Disabled.");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString formatDetails(const Mcp::McpServerConfig &cfg)
|
||||||
|
{
|
||||||
|
const QString muted = mutedColorHex();
|
||||||
|
const QString type = cfg.transport == Mcp::McpTransportKind::Http
|
||||||
|
? QStringLiteral("sse")
|
||||||
|
: QStringLiteral("stdio");
|
||||||
|
|
||||||
|
QString details;
|
||||||
|
if (cfg.transport == Mcp::McpTransportKind::Http) {
|
||||||
|
details = QString("<span style=\"color:%1\">%2</span>")
|
||||||
|
.arg(muted, cfg.url.toString().toHtmlEscaped());
|
||||||
|
} else {
|
||||||
|
QStringList parts;
|
||||||
|
if (!cfg.command.isEmpty())
|
||||||
|
parts << cfg.command.toHtmlEscaped();
|
||||||
|
for (const QString &a : cfg.args)
|
||||||
|
parts << a.toHtmlEscaped();
|
||||||
|
details = QString("<span style=\"color:%1\">%2</span>").arg(muted, parts.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cfg.env.isEmpty()) {
|
||||||
|
QStringList envKeys;
|
||||||
|
for (auto it = cfg.env.begin(); it != cfg.env.end(); ++it)
|
||||||
|
envKeys << it.key().toHtmlEscaped();
|
||||||
|
details
|
||||||
|
+= QString(" <span style=\"color:%1\">env: %2</span>")
|
||||||
|
.arg(muted, envKeys.join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
return QString("<b>%1</b> <span style=\"color:%2\">[%3]</span><br><tt>%4</tt>")
|
||||||
|
.arg(cfg.name.toHtmlEscaped(), muted, type, details);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ExamplePreset
|
||||||
|
{
|
||||||
|
QString label;
|
||||||
|
QString defaultName;
|
||||||
|
QJsonObject body;
|
||||||
|
};
|
||||||
|
|
||||||
|
QList<ExamplePreset> buildExamplePresets()
|
||||||
|
{
|
||||||
|
QList<ExamplePreset> out;
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{McpClientsListAspect::tr("everything (reference test server)"),
|
||||||
|
QStringLiteral("everything"),
|
||||||
|
QJsonObject{
|
||||||
|
{"enable", true},
|
||||||
|
{"type", "stdio"},
|
||||||
|
{"command", "npx"},
|
||||||
|
{"args", QJsonArray{"-y", "@modelcontextprotocol/server-everything"}}}});
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{McpClientsListAspect::tr("filesystem (local files)"),
|
||||||
|
QStringLiteral("filesystem"),
|
||||||
|
QJsonObject{
|
||||||
|
{"enable", true},
|
||||||
|
{"type", "stdio"},
|
||||||
|
{"command", "npx"},
|
||||||
|
{"args",
|
||||||
|
QJsonArray{
|
||||||
|
"-y", "@modelcontextprotocol/server-filesystem", QDir::homePath()}}}});
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{McpClientsListAspect::tr("memory (in-memory key-value)"),
|
||||||
|
QStringLiteral("memory"),
|
||||||
|
QJsonObject{
|
||||||
|
{"enable", true},
|
||||||
|
{"type", "stdio"},
|
||||||
|
{"command", "npx"},
|
||||||
|
{"args", QJsonArray{"-y", "@modelcontextprotocol/server-memory"}}}});
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{McpClientsListAspect::tr("git (local git ops)"),
|
||||||
|
QStringLiteral("git"),
|
||||||
|
QJsonObject{
|
||||||
|
{"enable", true},
|
||||||
|
{"type", "stdio"},
|
||||||
|
{"command", "uvx"},
|
||||||
|
{"args", QJsonArray{"mcp-server-git"}}}});
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{McpClientsListAspect::tr("time (system clock)"),
|
||||||
|
QStringLiteral("time"),
|
||||||
|
QJsonObject{
|
||||||
|
{"enable", true},
|
||||||
|
{"type", "stdio"},
|
||||||
|
{"command", "uvx"},
|
||||||
|
{"args", QJsonArray{"mcp-server-time"}}}});
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{McpClientsListAspect::tr("qtcreator (Qt Creator's built-in MCP server)"),
|
||||||
|
QStringLiteral("qtcreator"),
|
||||||
|
QJsonObject{
|
||||||
|
{"enable", false},
|
||||||
|
{"type", "sse"},
|
||||||
|
{"url", "http://127.0.0.1:3001/sse"},
|
||||||
|
{"spec", "2024-11-05"}}});
|
||||||
|
|
||||||
|
out.append(
|
||||||
|
{McpClientsListAspect::tr("remote (SSE / HTTP)"),
|
||||||
|
QStringLiteral("remote"),
|
||||||
|
QJsonObject{
|
||||||
|
{"enable", false},
|
||||||
|
{"type", "sse"},
|
||||||
|
{"url", "https://example.com/mcp"},
|
||||||
|
{"headers", QJsonObject{{"Authorization", "Bearer <token>"}}}}});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RowWidgets
|
||||||
|
{
|
||||||
|
QPointer<StatusDot> dot;
|
||||||
|
QPointer<QLabel> status;
|
||||||
|
QPointer<QLabel> tools;
|
||||||
|
};
|
||||||
|
|
||||||
|
void applyState(const RowWidgets &w, Mcp::McpServerConnection *conn)
|
||||||
|
{
|
||||||
|
if (!conn)
|
||||||
|
return;
|
||||||
|
if (w.dot) {
|
||||||
|
w.dot->setColor(dotColorFor(conn->state()));
|
||||||
|
w.dot->setToolTip(statusTooltip(conn->state(), conn->statusText()));
|
||||||
|
}
|
||||||
|
if (w.status) {
|
||||||
|
w.status->setText(
|
||||||
|
QString("<span style=\"color:%1\">%2</span>")
|
||||||
|
.arg(mutedColorHex(),
|
||||||
|
statusTooltip(conn->state(), conn->statusText()).toHtmlEscaped()));
|
||||||
|
}
|
||||||
|
if (w.tools) {
|
||||||
|
const QStringList names = conn->toolNames();
|
||||||
|
if (names.isEmpty()) {
|
||||||
|
if (conn->state() == Mcp::McpConnectionState::Connected) {
|
||||||
|
w.tools->setText(
|
||||||
|
QString("<i style=\"color:%1\">%2</i>")
|
||||||
|
.arg(mutedColorHex(),
|
||||||
|
McpClientsListAspect::tr("Server reports no tools.")));
|
||||||
|
w.tools->show();
|
||||||
|
} else {
|
||||||
|
w.tools->clear();
|
||||||
|
w.tools->hide();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
QStringList escaped;
|
||||||
|
escaped.reserve(names.size());
|
||||||
|
for (const QString &n : names)
|
||||||
|
escaped << n.toHtmlEscaped();
|
||||||
|
w.tools->setText(
|
||||||
|
QString("<b>%1</b> (%2): %3")
|
||||||
|
.arg(McpClientsListAspect::tr("Tools"))
|
||||||
|
.arg(names.size())
|
||||||
|
.arg(escaped.join(", ")));
|
||||||
|
w.tools->show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
QWidget *makeRow(Mcp::McpServerConnection *conn, QHash<QString, RowWidgets> *widgets, QWidget *host)
|
||||||
|
{
|
||||||
|
auto *entry = new QFrame(host);
|
||||||
|
entry->setFrameShape(QFrame::StyledPanel);
|
||||||
|
auto *outer = new QVBoxLayout(entry);
|
||||||
|
outer->setContentsMargins(8, 6, 8, 6);
|
||||||
|
outer->setSpacing(2);
|
||||||
|
|
||||||
|
auto *row = new QWidget(entry);
|
||||||
|
auto *rowLayout = new QHBoxLayout(row);
|
||||||
|
rowLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
rowLayout->setSpacing(6);
|
||||||
|
|
||||||
|
auto *dot = new StatusDot(row);
|
||||||
|
rowLayout->addWidget(dot, 0, Qt::AlignTop);
|
||||||
|
|
||||||
|
auto *check = new QCheckBox(row);
|
||||||
|
check->setChecked(conn->config().enabled);
|
||||||
|
check->setToolTip(McpClientsListAspect::tr("Enable / disable this MCP server"));
|
||||||
|
rowLayout->addWidget(check, 0, Qt::AlignTop);
|
||||||
|
|
||||||
|
auto *info = new QLabel(formatDetails(conn->config()), row);
|
||||||
|
info->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
info->setWordWrap(true);
|
||||||
|
rowLayout->addWidget(info, 1);
|
||||||
|
|
||||||
|
auto *statusLabel = new QLabel(row);
|
||||||
|
statusLabel->setMinimumWidth(120);
|
||||||
|
statusLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
|
||||||
|
rowLayout->addWidget(statusLabel, 0, Qt::AlignTop);
|
||||||
|
|
||||||
|
auto *removeBtn = new QPushButton(QStringLiteral("✕"), row);
|
||||||
|
removeBtn->setToolTip(McpClientsListAspect::tr("Remove this server from the config."));
|
||||||
|
removeBtn->setFlat(true);
|
||||||
|
removeBtn->setFixedWidth(24);
|
||||||
|
removeBtn->setCursor(Qt::PointingHandCursor);
|
||||||
|
removeBtn->setStyleSheet(
|
||||||
|
QString("QPushButton:hover { color: %1; }").arg(errorColorHex()));
|
||||||
|
rowLayout->addWidget(removeBtn, 0, Qt::AlignTop);
|
||||||
|
|
||||||
|
outer->addWidget(row);
|
||||||
|
|
||||||
|
auto *tools = new QLabel(entry);
|
||||||
|
tools->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
tools->setWordWrap(true);
|
||||||
|
tools->setContentsMargins(38, 0, 0, 0);
|
||||||
|
tools->hide();
|
||||||
|
outer->addWidget(tools);
|
||||||
|
|
||||||
|
RowWidgets w{dot, statusLabel, tools};
|
||||||
|
widgets->insert(conn->config().name, w);
|
||||||
|
applyState(w, conn);
|
||||||
|
|
||||||
|
QObject::connect(check, &QCheckBox::toggled, row, [name = conn->config().name](bool on) {
|
||||||
|
Mcp::McpClientsManager::instance().setServerEnabled(name, on);
|
||||||
|
});
|
||||||
|
|
||||||
|
QObject::connect(removeBtn, &QPushButton::clicked, row, [name = conn->config().name, host]() {
|
||||||
|
const auto reply = QMessageBox::question(
|
||||||
|
host,
|
||||||
|
McpClientsListAspect::tr("Remove server"),
|
||||||
|
McpClientsListAspect::tr("Remove server '%1' from the config?").arg(name),
|
||||||
|
QMessageBox::Yes | QMessageBox::No,
|
||||||
|
QMessageBox::No);
|
||||||
|
if (reply == QMessageBox::Yes)
|
||||||
|
Mcp::McpClientsManager::instance().removeServer(name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearLayout(QVBoxLayout *lay)
|
||||||
|
{
|
||||||
|
while (auto *item = lay->takeAt(0)) {
|
||||||
|
if (auto *w = item->widget())
|
||||||
|
w->deleteLater();
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
McpClientsListAspect::McpClientsListAspect(Utils::AspectContainer *container)
|
||||||
|
: Utils::BaseAspect(container)
|
||||||
|
{}
|
||||||
|
|
||||||
|
void McpClientsListAspect::addToLayoutImpl(Layouting::Layout &parent)
|
||||||
|
{
|
||||||
|
auto *outer = new QWidget();
|
||||||
|
auto *outerLayout = new QVBoxLayout(outer);
|
||||||
|
outerLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
outerLayout->setSpacing(4);
|
||||||
|
|
||||||
|
auto *openBtn = new QPushButton(tr("Open Config"), outer);
|
||||||
|
openBtn->setToolTip(Mcp::McpClientsManager::configFilePath());
|
||||||
|
auto *refreshBtn = new QPushButton(tr("Refresh MCP List"), outer);
|
||||||
|
|
||||||
|
auto *buttonsRow = new QHBoxLayout();
|
||||||
|
buttonsRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
buttonsRow->addWidget(openBtn);
|
||||||
|
buttonsRow->addWidget(refreshBtn);
|
||||||
|
buttonsRow->addStretch(1);
|
||||||
|
|
||||||
|
auto *restartHint = new QLabel(outer);
|
||||||
|
restartHint->setWordWrap(true);
|
||||||
|
restartHint->setText(
|
||||||
|
tr("Note: restart Qt Creator to apply MCP changes to already-opened chats "
|
||||||
|
"and running sessions."));
|
||||||
|
|
||||||
|
auto *summaryLabel = new QLabel(outer);
|
||||||
|
summaryLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
|
||||||
|
|
||||||
|
auto *serversHost = new QWidget(outer);
|
||||||
|
auto *serversLayout = new QVBoxLayout(serversHost);
|
||||||
|
serversLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
serversLayout->setSpacing(4);
|
||||||
|
|
||||||
|
auto *scroll = new QScrollArea(outer);
|
||||||
|
scroll->setWidgetResizable(true);
|
||||||
|
scroll->setMinimumHeight(160);
|
||||||
|
scroll->setFrameShape(QFrame::StyledPanel);
|
||||||
|
scroll->setWidget(serversHost);
|
||||||
|
|
||||||
|
auto *quickSetupLabel = new QLabel(tr("Quick Setup"), outer);
|
||||||
|
auto *presetsCombo = new QComboBox(outer);
|
||||||
|
presetsCombo->setToolTip(
|
||||||
|
tr("Pick a preset to append a ready-made server entry to the config "
|
||||||
|
"(auto-suffixed if the name is taken)."));
|
||||||
|
presetsCombo->setSizeAdjustPolicy(QComboBox::AdjustToContents);
|
||||||
|
presetsCombo->addItem(tr("-- Select Preset --"));
|
||||||
|
const auto presets = buildExamplePresets();
|
||||||
|
for (const auto &p : presets)
|
||||||
|
presetsCombo->addItem(p.label);
|
||||||
|
|
||||||
|
auto *btnRow = new QHBoxLayout();
|
||||||
|
btnRow->setContentsMargins(0, 0, 0, 0);
|
||||||
|
btnRow->addWidget(quickSetupLabel);
|
||||||
|
btnRow->addWidget(presetsCombo);
|
||||||
|
btnRow->addStretch(1);
|
||||||
|
|
||||||
|
outerLayout->addLayout(buttonsRow);
|
||||||
|
outerLayout->addWidget(restartHint);
|
||||||
|
outerLayout->addWidget(summaryLabel);
|
||||||
|
outerLayout->addWidget(scroll);
|
||||||
|
outerLayout->addLayout(btnRow);
|
||||||
|
|
||||||
|
auto rowsState = std::make_shared<QHash<QString, RowWidgets>>();
|
||||||
|
|
||||||
|
auto rebuild = [outer, serversLayout, summaryLabel, rowsState]() {
|
||||||
|
clearLayout(serversLayout);
|
||||||
|
rowsState->clear();
|
||||||
|
|
||||||
|
const auto connections = Mcp::McpClientsManager::instance().connections();
|
||||||
|
if (connections.isEmpty()) {
|
||||||
|
auto *empty = new QLabel(
|
||||||
|
QString("<p style=\"color:%1\">%2</p>")
|
||||||
|
.arg(mutedColorHex(),
|
||||||
|
tr("No servers configured. Add a preset below or edit the JSON.")),
|
||||||
|
outer);
|
||||||
|
serversLayout->addWidget(empty);
|
||||||
|
serversLayout->addStretch();
|
||||||
|
summaryLabel->setText(
|
||||||
|
QString("<i>%1</i>").arg(tr("0 server(s) defined.")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int enabled = 0;
|
||||||
|
for (auto *conn : connections) {
|
||||||
|
serversLayout->addWidget(makeRow(conn, rowsState.get(), outer));
|
||||||
|
if (conn->config().enabled)
|
||||||
|
++enabled;
|
||||||
|
}
|
||||||
|
serversLayout->addStretch();
|
||||||
|
|
||||||
|
summaryLabel->setText(
|
||||||
|
QString("<i>%1</i>")
|
||||||
|
.arg(tr("%1 server(s) defined, %2 enabled.")
|
||||||
|
.arg(connections.size())
|
||||||
|
.arg(enabled)));
|
||||||
|
};
|
||||||
|
|
||||||
|
rebuild();
|
||||||
|
|
||||||
|
QObject::connect(
|
||||||
|
&Mcp::McpClientsManager::instance(),
|
||||||
|
&Mcp::McpClientsManager::serversChanged,
|
||||||
|
outer,
|
||||||
|
[rebuild, rowsState]() {
|
||||||
|
const auto connections = Mcp::McpClientsManager::instance().connections();
|
||||||
|
|
||||||
|
QStringList currentNames;
|
||||||
|
currentNames.reserve(connections.size());
|
||||||
|
for (auto *c : connections)
|
||||||
|
currentNames << c->config().name;
|
||||||
|
|
||||||
|
QStringList knownNames = rowsState->keys();
|
||||||
|
std::sort(currentNames.begin(), currentNames.end());
|
||||||
|
std::sort(knownNames.begin(), knownNames.end());
|
||||||
|
|
||||||
|
if (currentNames != knownNames) {
|
||||||
|
rebuild();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto *conn : connections)
|
||||||
|
applyState(rowsState->value(conn->config().name), conn);
|
||||||
|
});
|
||||||
|
|
||||||
|
QObject::connect(refreshBtn, &QPushButton::clicked, outer, []() {
|
||||||
|
Mcp::McpClientsManager::instance().reload();
|
||||||
|
});
|
||||||
|
QObject::connect(openBtn, &QPushButton::clicked, outer, []() {
|
||||||
|
Core::EditorManager::openEditor(
|
||||||
|
Utils::FilePath::fromString(Mcp::McpClientsManager::configFilePath()));
|
||||||
|
});
|
||||||
|
QObject::connect(
|
||||||
|
&Mcp::McpClientsManager::instance(),
|
||||||
|
&Mcp::McpClientsManager::writeFailed,
|
||||||
|
outer,
|
||||||
|
[outer](const QString &reason) {
|
||||||
|
QMessageBox::warning(
|
||||||
|
outer,
|
||||||
|
McpClientsListAspect::tr("MCP configuration"),
|
||||||
|
McpClientsListAspect::tr("Failed to write %1:\n%2")
|
||||||
|
.arg(Mcp::McpClientsManager::configFilePath(), reason));
|
||||||
|
});
|
||||||
|
auto syncEnabled = [outer]() {
|
||||||
|
outer->setEnabled(mcpSettings().enableMcpClients.volatileValue());
|
||||||
|
};
|
||||||
|
syncEnabled();
|
||||||
|
QObject::connect(
|
||||||
|
&mcpSettings().enableMcpClients,
|
||||||
|
&Utils::BoolAspect::volatileValueChanged,
|
||||||
|
outer,
|
||||||
|
syncEnabled);
|
||||||
|
|
||||||
|
QObject::connect(
|
||||||
|
presetsCombo,
|
||||||
|
&QComboBox::currentIndexChanged,
|
||||||
|
outer,
|
||||||
|
[presetsCombo, presets](int idx) {
|
||||||
|
if (idx <= 0)
|
||||||
|
return;
|
||||||
|
const int presetIdx = idx - 1;
|
||||||
|
if (presetIdx < 0 || presetIdx >= presets.size()) {
|
||||||
|
presetsCombo->setCurrentIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Mcp::McpClientsManager::instance().addServer(
|
||||||
|
presets[presetIdx].defaultName, presets[presetIdx].body);
|
||||||
|
// Snap back to the placeholder so the next pick fires again even
|
||||||
|
// if the user chooses the same preset twice.
|
||||||
|
presetsCombo->setCurrentIndex(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
parent.addItem(outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
21
settings/McpClientsListAspect.hpp
Normal file
21
settings/McpClientsListAspect.hpp
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <utils/aspects.h>
|
||||||
|
#include <utils/layoutbuilder.h>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class McpClientsListAspect : public Utils::BaseAspect
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit McpClientsListAspect(Utils::AspectContainer *container = nullptr);
|
||||||
|
|
||||||
|
void addToLayoutImpl(Layouting::Layout &parent) override;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <utils/layoutbuilder.h>
|
#include <utils/layoutbuilder.h>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
|
#include <QDir>
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
#include <QDialogButtonBox>
|
#include <QDialogButtonBox>
|
||||||
#include <QFontDatabase>
|
#include <QFontDatabase>
|
||||||
@@ -51,6 +52,30 @@ McpSettings::McpSettings()
|
|||||||
mcpServerPort.setRange(1, 65535);
|
mcpServerPort.setRange(1, 65535);
|
||||||
mcpServerPort.setDefaultValue(3456);
|
mcpServerPort.setDefaultValue(3456);
|
||||||
|
|
||||||
|
enableMcpClients.setSettingsKey(Constants::MCP_ENABLE_CLIENTS);
|
||||||
|
enableMcpClients.setLabelText(Tr::tr("Connect to external MCP servers"));
|
||||||
|
enableMcpClients.setToolTip(
|
||||||
|
Tr::tr("Connect to MCP servers listed in mcp-server.json and expose their tools "
|
||||||
|
"to chat/quick-refactor/code-completion. Toggling this off disconnects all "
|
||||||
|
"currently running MCP client sessions."));
|
||||||
|
enableMcpClients.setDefaultValue(false);
|
||||||
|
|
||||||
|
mcpClientExtraPaths.setSettingsKey(Constants::MCP_CLIENT_EXTRA_PATHS);
|
||||||
|
mcpClientExtraPaths.setLabelText(Tr::tr("Extra PATH for stdio servers"));
|
||||||
|
mcpClientExtraPaths.setDisplayStyle(Utils::StringAspect::LineEditDisplay);
|
||||||
|
mcpClientExtraPaths.setToolTip(
|
||||||
|
Tr::tr("Directories to prepend to PATH when launching stdio MCP servers. "
|
||||||
|
"Useful when Qt Creator is started from the dock and doesn't see Homebrew, "
|
||||||
|
"nvm, uv, etc. Separate multiple entries with '%1'. "
|
||||||
|
"Per-server 'env' overrides in mcp-server.json still win.")
|
||||||
|
.arg(QDir::listSeparator()));
|
||||||
|
#ifdef Q_OS_MACOS
|
||||||
|
mcpClientExtraPaths.setDefaultValue(
|
||||||
|
QStringLiteral("/opt/homebrew/bin:/usr/local/bin"));
|
||||||
|
#else
|
||||||
|
mcpClientExtraPaths.setDefaultValue(QString{});
|
||||||
|
#endif
|
||||||
|
|
||||||
resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults");
|
resetToDefaults.m_buttonText = Tr::tr("Reset Page to Defaults");
|
||||||
showConnectionInstructions.m_buttonText = Tr::tr("How to connect...");
|
showConnectionInstructions.m_buttonText = Tr::tr("How to connect...");
|
||||||
|
|
||||||
@@ -70,6 +95,10 @@ McpSettings::McpSettings()
|
|||||||
enableMcpServer,
|
enableMcpServer,
|
||||||
mcpServerPort,
|
mcpServerPort,
|
||||||
Row{Stretch{1}, showConnectionInstructions}}},
|
Row{Stretch{1}, showConnectionInstructions}}},
|
||||||
|
Space{8},
|
||||||
|
Group{
|
||||||
|
title(Tr::tr("Clients")),
|
||||||
|
Column{enableMcpClients, mcpClientExtraPaths, mcpClientsList}},
|
||||||
Stretch{1}};
|
Stretch{1}};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -87,6 +116,28 @@ void McpSettings::setupConnections()
|
|||||||
&ButtonAspect::clicked,
|
&ButtonAspect::clicked,
|
||||||
this,
|
this,
|
||||||
&McpSettings::showConnectionInstructionsDialog);
|
&McpSettings::showConnectionInstructionsDialog);
|
||||||
|
|
||||||
|
auto syncServerSubgroup = [this]() {
|
||||||
|
const bool on = enableMcpServer.volatileValue();
|
||||||
|
mcpServerPort.setEnabled(on);
|
||||||
|
};
|
||||||
|
auto syncClientsSubgroup = [this]() {
|
||||||
|
const bool on = enableMcpClients.volatileValue();
|
||||||
|
mcpClientExtraPaths.setEnabled(on);
|
||||||
|
mcpClientsList.setEnabled(on);
|
||||||
|
};
|
||||||
|
connect(
|
||||||
|
&enableMcpServer,
|
||||||
|
&Utils::BoolAspect::volatileValueChanged,
|
||||||
|
this,
|
||||||
|
syncServerSubgroup);
|
||||||
|
connect(
|
||||||
|
&enableMcpClients,
|
||||||
|
&Utils::BoolAspect::volatileValueChanged,
|
||||||
|
this,
|
||||||
|
syncClientsSubgroup);
|
||||||
|
syncServerSubgroup();
|
||||||
|
syncClientsSubgroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
void McpSettings::resetSettingsToDefaults()
|
void McpSettings::resetSettingsToDefaults()
|
||||||
@@ -101,6 +152,8 @@ void McpSettings::resetSettingsToDefaults()
|
|||||||
if (reply == QMessageBox::Yes) {
|
if (reply == QMessageBox::Yes) {
|
||||||
resetAspect(enableMcpServer);
|
resetAspect(enableMcpServer);
|
||||||
resetAspect(mcpServerPort);
|
resetAspect(mcpServerPort);
|
||||||
|
resetAspect(enableMcpClients);
|
||||||
|
resetAspect(mcpClientExtraPaths);
|
||||||
writeSettings();
|
writeSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <utils/aspects.h>
|
#include <utils/aspects.h>
|
||||||
|
|
||||||
#include "ButtonAspect.hpp"
|
#include "ButtonAspect.hpp"
|
||||||
|
#include "McpClientsListAspect.hpp"
|
||||||
|
|
||||||
namespace QodeAssist::Settings {
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ public:
|
|||||||
|
|
||||||
ButtonAspect showConnectionInstructions{this};
|
ButtonAspect showConnectionInstructions{this};
|
||||||
|
|
||||||
|
Utils::BoolAspect enableMcpClients{this};
|
||||||
|
Utils::StringAspect mcpClientExtraPaths{this};
|
||||||
|
McpClientsListAspect mcpClientsList{this};
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void setupConnections();
|
void setupConnections();
|
||||||
void resetSettingsToDefaults();
|
void resetSettingsToDefaults();
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ const char CA_TERMINAL_COMMAND_TIMEOUT[] = "QodeAssist.caTerminalCommandTimeout"
|
|||||||
// MCP server settings
|
// MCP server settings
|
||||||
const char MCP_ENABLE_SERVER[] = "QodeAssist.mcpEnableServer";
|
const char MCP_ENABLE_SERVER[] = "QodeAssist.mcpEnableServer";
|
||||||
const char MCP_SERVER_PORT[] = "QodeAssist.mcpServerPort";
|
const char MCP_SERVER_PORT[] = "QodeAssist.mcpServerPort";
|
||||||
|
const char MCP_ENABLE_CLIENTS[] = "QodeAssist.mcpEnableClients";
|
||||||
|
const char MCP_CLIENT_EXTRA_PATHS[] = "QodeAssist.mcpClientExtraPaths";
|
||||||
|
|
||||||
const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions";
|
const char QODE_ASSIST_GENERAL_OPTIONS_ID[] = "QodeAssist.GeneralOptions";
|
||||||
const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId";
|
const char QODE_ASSIST_GENERAL_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId";
|
||||||
|
|||||||
43
settings/StatusDot.hpp
Normal file
43
settings/StatusDot.hpp
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (C) 2024-2026 Petr Mironychev
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QWidget>
|
||||||
|
|
||||||
|
namespace QodeAssist::Settings {
|
||||||
|
|
||||||
|
class StatusDot : public QWidget
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit StatusDot(QWidget *parent = nullptr)
|
||||||
|
: QWidget(parent)
|
||||||
|
{
|
||||||
|
setFixedSize(12, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setColor(const QColor &color)
|
||||||
|
{
|
||||||
|
if (m_color == color)
|
||||||
|
return;
|
||||||
|
m_color = color;
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void paintEvent(QPaintEvent *) override
|
||||||
|
{
|
||||||
|
QPainter p(this);
|
||||||
|
p.setRenderHint(QPainter::Antialiasing);
|
||||||
|
p.setPen(Qt::NoPen);
|
||||||
|
p.setBrush(m_color);
|
||||||
|
p.drawEllipse(rect().adjusted(2, 2, -2, -2));
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
QColor m_color{Qt::gray};
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace QodeAssist::Settings
|
||||||
2
sources/external/llmqore
vendored
2
sources/external/llmqore
vendored
Submodule sources/external/llmqore updated: 55a4e293fe...011b24ec78
Reference in New Issue
Block a user