feat: Add mcp client hub

This commit is contained in:
Petr Mironychev
2026-04-23 09:37:36 +02:00
parent 90b7ed26b1
commit 620fded2e1
13 changed files with 1545 additions and 1 deletions

View 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(" &nbsp; <span style=\"color:%1\">env: %2</span>")
.arg(muted, envKeys.join(", "));
}
return QString("<b>%1</b> &nbsp; <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

View 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

View File

@@ -8,6 +8,7 @@
#include <utils/layoutbuilder.h>
#include <QApplication>
#include <QClipboard>
#include <QDir>
#include <QDialog>
#include <QDialogButtonBox>
#include <QFontDatabase>
@@ -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();
}
}

View File

@@ -6,6 +6,7 @@
#include <utils/aspects.h>
#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();

View File

@@ -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";

43
settings/StatusDot.hpp Normal file
View 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