diff --git a/CMakeLists.txt b/CMakeLists.txt index 7117dc2..3fab07f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -158,6 +158,9 @@ add_qtc_plugin(QodeAssist tools/FileSearchUtils.hpp tools/FileSearchUtils.cpp tools/TodoTool.hpp tools/TodoTool.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) diff --git a/mcp/McpClientsManager.cpp b/mcp/McpClientsManager.cpp new file mode 100644 index 0000000..6a01a38 --- /dev/null +++ b/mcp/McpClientsManager.cpp @@ -0,0 +1,364 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "McpClientsManager.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +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 McpClientsManager::connections() const +{ + return m_connections; +} + +QList McpClientsManager::toolsCapableProviders() const +{ + QList 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 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 keep; + QList 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 diff --git a/mcp/McpClientsManager.hpp b/mcp/McpClientsManager.hpp new file mode 100644 index 0000000..dfaf28c --- /dev/null +++ b/mcp/McpClientsManager.hpp @@ -0,0 +1,63 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include + +#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 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 toolsCapableProviders() const; + QJsonObject readRoot() const; + bool writeRoot(const QJsonObject &root); + + bool m_initialized = false; + bool m_suppressNextWatcherReload = false; + QList m_connections; + QPointer m_watcher; + QPointer m_reloadDebounce; +}; + +} // namespace QodeAssist::Mcp diff --git a/mcp/McpServerConnection.cpp b/mcp/McpServerConnection.cpp new file mode 100644 index 0000000..10c66eb --- /dev/null +++ b/mcp/McpServerConnection.cpp @@ -0,0 +1,391 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "McpServerConnection.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +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 &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 diff --git a/mcp/McpServerConnection.hpp b/mcp/McpServerConnection.hpp new file mode 100644 index 0000000..ab1059d --- /dev/null +++ b/mcp/McpServerConnection.hpp @@ -0,0 +1,93 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 headers; + QString spec; // MCP protocol spec, e.g. "2024-11-05"; empty = Latest. + + // Stdio transport + QString command; + QStringList args; + QHash 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 &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 m_listToolsWatchdog; + + QList> m_providers; + QStringList m_toolIds; +}; + +} // namespace QodeAssist::Mcp diff --git a/qodeassist.cpp b/qodeassist.cpp index 9138d52..0df4d42 100644 --- a/qodeassist.cpp +++ b/qodeassist.cpp @@ -37,6 +37,7 @@ #include "pluginllmcore/PromptProviderFim.hpp" #include "pluginllmcore/ProvidersManager.hpp" #include "logger/RequestPerformanceLogger.hpp" +#include "mcp/McpClientsManager.hpp" #include "mcp/McpServerManager.hpp" #include "providers/Providers.hpp" #include "settings/ChatAssistantSettings.hpp" @@ -167,6 +168,8 @@ public: m_mcpServerManager = new Mcp::McpServerManager(this); m_mcpServerManager->init(); + Mcp::McpClientsManager::instance().init(); + if (Settings::generalSettings().enableCheckUpdate()) { QTimer::singleShot(3000, this, &QodeAssistPlugin::checkForUpdates); } diff --git a/settings/McpClientsListAspect.cpp b/settings/McpClientsListAspect.cpp new file mode 100644 index 0000000..8eae67f --- /dev/null +++ b/settings/McpClientsListAspect.cpp @@ -0,0 +1,503 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "McpClientsListAspect.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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("%2") + .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("%2").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("   env: %2") + .arg(muted, envKeys.join(", ")); + } + + return QString("%1   [%3]
%4") + .arg(cfg.name.toHtmlEscaped(), muted, type, details); +} + +struct ExamplePreset +{ + QString label; + QString defaultName; + QJsonObject body; +}; + +QList buildExamplePresets() +{ + QList 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 "}}}}}); + + return out; +} + +struct RowWidgets +{ + QPointer dot; + QPointer status; + QPointer 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("%2") + .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("%2") + .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("%1 (%2): %3") + .arg(McpClientsListAspect::tr("Tools")) + .arg(names.size()) + .arg(escaped.join(", "))); + w.tools->show(); + } + } +} + +QWidget *makeRow(Mcp::McpServerConnection *conn, QHash *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>(); + + 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("

%2

") + .arg(mutedColorHex(), + tr("No servers configured. Add a preset below or edit the JSON.")), + outer); + serversLayout->addWidget(empty); + serversLayout->addStretch(); + summaryLabel->setText( + QString("%1").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("%1") + .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 diff --git a/settings/McpClientsListAspect.hpp b/settings/McpClientsListAspect.hpp new file mode 100644 index 0000000..db6afd9 --- /dev/null +++ b/settings/McpClientsListAspect.hpp @@ -0,0 +1,21 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +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 diff --git a/settings/McpSettings.cpp b/settings/McpSettings.cpp index f73d163..64651df 100644 --- a/settings/McpSettings.cpp +++ b/settings/McpSettings.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,30 @@ McpSettings::McpSettings() mcpServerPort.setRange(1, 65535); 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"); showConnectionInstructions.m_buttonText = Tr::tr("How to connect..."); @@ -70,6 +95,10 @@ McpSettings::McpSettings() enableMcpServer, mcpServerPort, Row{Stretch{1}, showConnectionInstructions}}}, + Space{8}, + Group{ + title(Tr::tr("Clients")), + Column{enableMcpClients, mcpClientExtraPaths, mcpClientsList}}, Stretch{1}}; }); } @@ -87,6 +116,28 @@ void McpSettings::setupConnections() &ButtonAspect::clicked, this, &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() @@ -101,6 +152,8 @@ void McpSettings::resetSettingsToDefaults() if (reply == QMessageBox::Yes) { resetAspect(enableMcpServer); resetAspect(mcpServerPort); + resetAspect(enableMcpClients); + resetAspect(mcpClientExtraPaths); writeSettings(); } } diff --git a/settings/McpSettings.hpp b/settings/McpSettings.hpp index 86c47d2..7159504 100644 --- a/settings/McpSettings.hpp +++ b/settings/McpSettings.hpp @@ -6,6 +6,7 @@ #include #include "ButtonAspect.hpp" +#include "McpClientsListAspect.hpp" namespace QodeAssist::Settings { @@ -21,6 +22,10 @@ public: ButtonAspect showConnectionInstructions{this}; + Utils::BoolAspect enableMcpClients{this}; + Utils::StringAspect mcpClientExtraPaths{this}; + McpClientsListAspect mcpClientsList{this}; + private: void setupConnections(); void resetSettingsToDefaults(); diff --git a/settings/SettingsConstants.hpp b/settings/SettingsConstants.hpp index be615d6..bfae286 100644 --- a/settings/SettingsConstants.hpp +++ b/settings/SettingsConstants.hpp @@ -103,6 +103,8 @@ const char CA_TERMINAL_COMMAND_TIMEOUT[] = "QodeAssist.caTerminalCommandTimeout" // MCP server settings const char MCP_ENABLE_SERVER[] = "QodeAssist.mcpEnableServer"; 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_SETTINGS_PAGE_ID[] = "QodeAssist.1GeneralSettingsPageId"; diff --git a/settings/StatusDot.hpp b/settings/StatusDot.hpp new file mode 100644 index 0000000..849c58c --- /dev/null +++ b/settings/StatusDot.hpp @@ -0,0 +1,43 @@ +// Copyright (C) 2024-2026 Petr Mironychev +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include + +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 diff --git a/sources/external/llmqore b/sources/external/llmqore index 55a4e29..011b24e 160000 --- a/sources/external/llmqore +++ b/sources/external/llmqore @@ -1 +1 @@ -Subproject commit 55a4e293fe2b95596e8626a793dfd36ad67144f6 +Subproject commit 011b24ec7866f275cbeaa10b8bbbbafa68cb2f3d