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:
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
|
||||
Reference in New Issue
Block a user