Files

677 lines
28 KiB
C++
Raw Permalink Normal View History

2026-09-11 20:11:10 +01:00
///////////////////////////////////////////////////////////////////////////////////
// Copyright (C) 2026 Jon Beniston, M7RCE <jon@beniston.com> //
// Some code by AI //
// //
// This program is free software; you can redistribute it and/or modify //
// it under the terms of the GNU General Public License as published by //
// the Free Software Foundation as version 3 of the License, or //
// (at your option) any later version. //
// //
// This program is distributed in the hope that it will be useful, //
// but WITHOUT ANY WARRANTY; without even the implied warranty of //
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the //
// GNU General Public License V3 for more details. //
// //
// You should have received a copy of the GNU General Public License //
// along with this program. If not, see <http://www.gnu.org/licenses/>. //
///////////////////////////////////////////////////////////////////////////////////
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QCoreApplication>
#include "mcpstreams.h"
#include "mcpprotocol.h"
const char* const MCPProtocol::m_serverName = "SDRangel";
const char* const MCPProtocol::m_latestProtocolVersion = "2025-06-18";
MCPProtocol::MCPProtocol(WebAPIAdapterInterface *webAPIAdapterInterface) :
m_webAPIAdapterInterface(webAPIAdapterInterface),
m_tools(webAPIAdapterInterface),
m_streams(nullptr)
{
}
QJsonObject MCPProtocol::makeResult(const QJsonValue& id, const QJsonValue& result)
{
QJsonObject response;
response["jsonrpc"] = "2.0";
response["id"] = id;
response["result"] = result;
return response;
}
QJsonObject MCPProtocol::makeError(const QJsonValue& id, int code, const QString& message)
{
QJsonObject error;
error["code"] = code;
error["message"] = message;
QJsonObject response;
response["jsonrpc"] = "2.0";
response["id"] = id.isUndefined() ? QJsonValue() : id;
response["error"] = error;
return response;
}
// JSON-RPC 2.0 rules: a message with an "id" member is a request and always gets a reply
// carrying that id, even if the id is null (MCP forbids null ids but a client that sends one
// must still learn what happened). A message with a method and no id is a notification and
// gets no reply. Anything that is neither a valid request, notification nor response is
// answered with an Invalid Request error whose id is null when it cannot be identified.
bool MCPProtocol::handleMessage(const QJsonObject& message, QJsonObject& response, Context& context)
{
bool hasId = message.contains("id");
QJsonValue id = hasId ? message["id"] : QJsonValue();
bool hasMethod = message["method"].isString() && !message["method"].toString().isEmpty();
if (!hasMethod)
{
// A response to a server initiated request. We never send any, so there is nothing to do.
if (hasId && (message.contains("result") || message.contains("error"))) {
return false;
}
response = makeError(id, InvalidRequest, "Invalid request: missing method");
return true;
}
if (message["jsonrpc"].toString() != "2.0")
{
response = makeError(id, InvalidRequest, "Invalid request: jsonrpc must be \"2.0\"");
return true;
}
QString method = message["method"].toString();
QJsonObject params = message["params"].toObject();
try
{
// Calls are serialized so that tool handlers never run concurrently, except for the
// capture tools: those deliberately block for up to 30 seconds, and holding the lock
// across one would stall every other request, including ping and the status calls a
// client needs while a capture runs. They protect their own state instead.
QJsonValue result;
if (isLongRunning(method, params))
{
result = dispatch(method, params, context);
}
else
{
QMutexLocker locker(&m_mutex);
result = dispatch(method, params, context);
}
if (!hasId) {
return false; // notification: no reply
}
response = makeResult(id, result);
return true;
}
catch (const MCPError& e)
{
qDebug() << "MCPProtocol::handleMessage:" << method << "error:" << e.code << e.message;
if (!hasId) {
return false;
}
response = makeError(id, e.code, e.message);
return true;
}
catch (const MCPToolError& e)
{
// A tool level failure raised outside tools/call: resources/read shares the tool
// helpers, and a resource that is not there (a device set index that does not exist,
// an unknown schema type) surfaces here. Left uncaught it would leave the HTTP thread
// and take the application down, since MCPToolError is not a std::exception
qDebug() << "MCPProtocol::handleMessage:" << method << "error:" << e.message;
if (!hasId) {
return false;
}
response = makeError(id, method.startsWith("resources/") ? ResourceNotFound : InvalidParams, e.message);
return true;
}
2026-09-11 20:11:10 +01:00
catch (const std::exception& e)
{
qWarning() << "MCPProtocol::handleMessage:" << method << "exception:" << e.what();
if (!hasId) {
return false;
}
response = makeError(id, InternalError, QString("Internal error: %1").arg(e.what()));
return true;
}
catch (...)
{
// Nothing thrown here may reach the connection thread, which has no handler
qWarning() << "MCPProtocol::handleMessage:" << method << "unknown exception";
if (!hasId) {
return false;
}
response = makeError(id, InternalError, "Internal error");
return true;
}
2026-09-11 20:11:10 +01:00
}
// The capture tools sleep for the requested duration, so they run without the dispatch lock.
// MCPCapture and MCPAudioCapture each serialize their own work, and everything they call
// through the Web API adapter is safe to use from several threads.
bool MCPProtocol::isLongRunning(const QString& method, const QJsonObject& params)
{
if (method != "tools/call") {
return false;
}
static const QStringList blocking = {"record_iq", "capture_audio", "listen", "scan"};
return blocking.contains(params["name"].toString());
}
QJsonValue MCPProtocol::dispatch(const QString& method, const QJsonObject& params, Context& context)
{
if (method == "initialize") {
return initialize(params, context);
} else if (method == "ping") {
return QJsonObject();
} else if (method.startsWith("notifications/")) {
return QJsonValue(); // initialized, cancelled, progress, roots/list_changed: nothing to do
} else if (method == "tools/list") {
return toolsList(params);
} else if (method == "tools/call") {
return toolsCall(params);
} else if (method == "resources/list") {
return resourcesList(params);
} else if (method == "resources/templates/list") {
return resourcesTemplatesList(params);
} else if (method == "resources/read") {
return resourcesRead(params);
} else if (method == "resources/subscribe") {
return resourcesSubscribe(params, context, true);
} else if (method == "resources/unsubscribe") {
return resourcesSubscribe(params, context, false);
} else if (method == "prompts/list") {
return promptsList(params);
} else if (method == "prompts/get") {
return promptsGet(params);
} else if (method == "logging/setLevel") {
return QJsonObject();
} else if (method == "completion/complete") {
QJsonObject completion;
completion["values"] = QJsonArray();
completion["hasMore"] = false;
QJsonObject result;
result["completion"] = completion;
return result;
} else {
throw MCPError(MethodNotFound, QString("Method not found: %1").arg(method));
}
}
// Protocol revisions this server implements, newest first. 2024-11-05 is deliberately absent:
// it specifies the two endpoint HTTP+SSE transport, with a separate message endpoint announced
// by an initial endpoint event, and this server only implements Streamable HTTP.
QStringList MCPProtocol::supportedVersions()
{
return QStringList{"2025-06-18", "2025-03-26"};
}
bool MCPProtocol::isSupportedVersion(const QString& version)
{
return supportedVersions().contains(version);
}
QJsonValue MCPProtocol::initialize(const QJsonObject& params, Context& context)
{
// Echo the client's version if we implement it, otherwise offer the newest we have
QString requested = params["protocolVersion"].toString();
QString version = isSupportedVersion(requested) ? requested : QString(m_latestProtocolVersion);
QJsonObject clientInfo = params["clientInfo"].toObject();
qInfo("MCPProtocol::initialize: client %s %s protocol %s",
qPrintable(clientInfo["name"].toString()),
qPrintable(clientInfo["version"].toString()),
qPrintable(requested));
QJsonObject tools;
tools["listChanged"] = false;
QJsonObject resources;
resources["subscribe"] = true;
resources["listChanged"] = false;
QJsonObject prompts;
prompts["listChanged"] = false;
QJsonObject capabilities;
capabilities["tools"] = tools;
capabilities["resources"] = resources;
capabilities["prompts"] = prompts;
QJsonObject serverInfo;
serverInfo["name"] = m_serverName;
serverInfo["title"] = "SDRangel SDR";
serverInfo["version"] = qApp->applicationVersion();
// A session lets subscriptions and the event stream of this client be told apart from
// those of any other. Clients that ignore the session id share an anonymous one.
if (m_streams) {
context.m_newSessionId = m_streams->createSession();
}
QJsonObject result;
result["protocolVersion"] = version;
result["capabilities"] = capabilities;
result["serverInfo"] = serverInfo;
result["instructions"] = instructions();
return result;
}
// resources/subscribe and resources/unsubscribe. Updates are delivered as
// notifications/resources/updated on the SSE stream opened with GET.
QJsonValue MCPProtocol::resourcesSubscribe(const QJsonObject& params, Context& context, bool subscribe)
{
QString uri = params["uri"].toString();
if (uri.isEmpty()) {
throw MCPError(InvalidParams, "Invalid params: missing uri");
}
if (!m_streams) {
throw MCPError(InternalError, "Subscriptions are not available");
}
// Only the resources that change are worth subscribing to
static const QStringList subscribable = {
"sdrangel://instance", "sdrangel://packets", "sdrangel://map/items"
};
if (subscribe && !subscribable.contains(uri) && !uri.startsWith("sdrangel://deviceset/"))
{
throw MCPError(InvalidParams, QString("Resource %1 does not change, so it cannot be subscribed to. "
"Subscribable resources are %2 and sdrangel://deviceset/{index}").arg(uri).arg(subscribable.join(", ")));
}
// A device set URI has to name one that could exist: the index the Web API accepts is two
// digits, so anything else is a mistake rather than a resource to watch for
if (subscribe && uri.startsWith("sdrangel://deviceset/"))
{
static const QRegularExpression deviceSetUri("^sdrangel://deviceset/([0-9]{1,2})$");
if (!deviceSetUri.match(uri).hasMatch()) {
throw MCPError(InvalidParams, QString("Resource %1 is not a device set: use sdrangel://deviceset/{index} with an index from 0 to 99").arg(uri));
}
}
if (subscribe)
{
if (!m_streams->subscribe(context.m_sessionId, uri)) {
throw MCPError(InvalidParams, QString("This session already has %1 subscriptions; unsubscribe from one first").arg(MCPStreams::m_maxSubscriptions));
}
}
else
{
2026-09-11 20:11:10 +01:00
m_streams->unsubscribe(context.m_sessionId, uri);
}
return QJsonObject();
}
QString MCPProtocol::instructions()
{
return
"SDRangel is a software defined radio (SDR) application. This server controls a running SDRangel instance.\n"
"\n"
"Start with the intent tools: listen (receive a frequency in a mode, one call) and scan (find and follow activity across "
"frequencies). get_status is the cheapest view of what is running. Use the object level tools to adjust what they set up.\n"
"\n"
"Object model: a device set (R0, R1... for receivers, T0... for transmitters) holds one sampling device (an SDR such as an "
"RTL-SDR) and any number of channels (demodulators/modulators) processing its baseband. Device settings include centerFrequency "
"(Hz), sample rate and gain; channel settings include inputFrequencyOffset (Hz from the device centre). Features are device "
"independent plugins (Map, AIS, APRS...) in the single feature set (index 0).\n"
"\n"
"Settings keys are specific to each type, but the common ones (centerFrequency, inputFrequencyOffset, rfBandwidth, squelch, "
"volume, gain) can be set straight away: a set_* tool applies what it recognises, replies with just the keys changed, and names "
"any it did not recognise. Call describe_settings only for an unfamiliar key or when you need its range. get_receiving_guide "
"says which demodulator, frequency and sample rate a named signal needs; get_docs has a plugin's full readme, by section.\n"
"\n"
"A receiver's baseband rate (for RTL-SDR, devSampleRate / 2^log2Decim) must exceed every channel's RF bandwidth; listen "
"chooses it for you, and get_channel_report shows the resulting channelSampleRate.\n"
"\n"
"Recording: record_iq writes baseband IQ for a fixed time, start_iq_recording/stop_iq_recording for longer; capture_audio "
"writes a demodulator's audio to WAV and says whether it was silent. Files go under the capture directory shown by "
"get_server_status. Live data: get_packets (AIS, APRS, LoRa, M17, Meshtastic, Inmarsat, radiosondes) and get_map_items "
"(aircraft, ships, sondes, satellites) return what running plugins have decoded.\n"
"\n"
"Frequencies are in Hz. Direction is \"rx\" or \"tx\". Creation tools wait for SDRangel to finish and return the new index; "
"setting changes are applied asynchronously.";
}
QJsonValue MCPProtocol::toolsList(const QJsonObject& params)
{
(void) params;
QJsonObject result;
result["tools"] = m_tools.listTools();
return result;
}
QJsonValue MCPProtocol::toolsCall(const QJsonObject& params)
{
QString name = params["name"].toString();
if (name.isEmpty()) {
throw MCPError(InvalidParams, "Invalid params: missing tool name");
}
return m_tools.callTool(name, params["arguments"].toObject());
}
QJsonObject MCPProtocol::textContent(const QString& text)
{
QJsonObject content;
content["type"] = "text";
content["text"] = text;
return content;
}
struct MCPResourceDef {
const char *uri;
const char *name;
const char *description;
};
static const MCPResourceDef staticResources[] = {
{"sdrangel://instance", "Instance summary", "Version, device sets with their devices and channels, and features currently configured"},
{"sdrangel://plugins/devices", "Available sampling devices", "SDR hardware (and software sources/sinks) that can be selected in a device set"},
{"sdrangel://plugins/channels", "Available channel types", "Demodulator and modulator plugins that can be added to a device set"},
{"sdrangel://plugins/features", "Available feature types", "Feature plugins that can be added to the feature set"},
{"sdrangel://presets", "Presets", "Saved device set presets (device and channel settings) grouped by name"},
{"sdrangel://configurations", "Configurations", "Saved whole-instance configurations grouped by name"},
{"sdrangel://guide", "Receiving guide", "Which demodulator and frequency to use for a given signal, the minimum sample rate some modes need, and why nothing is received"},
{"sdrangel://docs", "Documentation index", "Documentation available for the devices, channels and features registered in this instance, and for GUI pages such as the spectrum display, with section headings"},
2026-09-11 20:11:10 +01:00
{"sdrangel://packets", "Recent packets", "The most recent packets decoded by AIS, packet (AX.25/APRS), LoRa, M17, Meshtastic, MeshCore, Inmarsat and radiosonde demodulators"},
{"sdrangel://map/items", "Map items", "Objects currently plotted on the map: aircraft, ships, APRS stations, radiosondes, satellites, beacons"},
};
QJsonValue MCPProtocol::resourcesList(const QJsonObject& params)
{
(void) params;
QJsonArray resources;
for (const auto& def : staticResources)
{
QJsonObject resource;
resource["uri"] = def.uri;
resource["name"] = def.name;
resource["description"] = def.description;
resource["mimeType"] = "application/json";
resources.append(resource);
}
QJsonObject result;
result["resources"] = resources;
return result;
}
QJsonValue MCPProtocol::resourcesTemplatesList(const QJsonObject& params)
{
(void) params;
QJsonArray templates;
QJsonObject schema;
schema["uriTemplate"] = "sdrangel://schema/{type}";
schema["name"] = "Settings schema";
schema["description"] = "Documentation of the settings, report and actions keys for a device (hardware id such as RTLSDR), channel (type such as ADSBDemod) or feature (type such as Map)";
schema["mimeType"] = "text/plain";
templates.append(schema);
QJsonObject deviceset;
deviceset["uriTemplate"] = "sdrangel://deviceset/{deviceSetIndex}";
deviceset["name"] = "Device set";
deviceset["description"] = "Sampling device and channels of a device set";
deviceset["mimeType"] = "application/json";
templates.append(deviceset);
QJsonObject docs;
docs["uriTemplate"] = "sdrangel://docs/{kind}/{id}";
docs["name"] = "Plugin documentation";
docs["description"] = "User documentation (readme) of a plugin: kind is device, channel or feature and id is the type id (e.g. channel/ADSBDemod, device/RTLSDR, feature/Map)";
docs["mimeType"] = "text/markdown";
templates.append(docs);
QJsonObject result;
result["resourceTemplates"] = templates;
return result;
}
QJsonValue MCPProtocol::resourcesRead(const QJsonObject& params)
{
QString uri = params["uri"].toString();
QJsonObject content;
content["uri"] = uri;
if (uri == "sdrangel://instance")
{
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.getInstanceSummary()).toJson(QJsonDocument::Compact));
}
else if (uri == "sdrangel://plugins/devices")
{
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.getAvailableDevices()).toJson(QJsonDocument::Compact));
}
else if (uri == "sdrangel://plugins/channels")
{
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.getAvailableChannels()).toJson(QJsonDocument::Compact));
}
else if (uri == "sdrangel://plugins/features")
{
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.getAvailableFeatures()).toJson(QJsonDocument::Compact));
}
else if (uri == "sdrangel://presets")
{
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.getPresets()).toJson(QJsonDocument::Compact));
}
else if (uri == "sdrangel://configurations")
{
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.getConfigurations()).toJson(QJsonDocument::Compact));
}
else if (uri.startsWith("sdrangel://schema/"))
{
content["mimeType"] = "text/plain";
content["text"] = m_tools.describeType(uri.mid(QString("sdrangel://schema/").size()), "");
}
else if (uri == "sdrangel://guide")
{
content["mimeType"] = "text/markdown";
content["text"] = m_tools.docs().guide();
}
else if (uri == "sdrangel://packets")
{
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.dataFeed().getPackets(QString(), QString(), 100, 0)).toJson(QJsonDocument::Compact));
}
else if (uri == "sdrangel://map/items")
{
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.dataFeed().getMapItems(QString(), QString(), 500, false)).toJson(QJsonDocument::Compact));
}
else if (uri == "sdrangel://docs")
{
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.docs().index()).toJson(QJsonDocument::Compact));
}
else if (uri.startsWith("sdrangel://docs/"))
{
QString rest = uri.mid(QString("sdrangel://docs/").size());
QString kind = rest.section('/', 0, 0);
QString id = rest.section('/', 1);
const MCPDocs::Doc *doc = m_tools.docs().find(id, kind);
if (!doc) {
throw MCPError(ResourceNotFound, QString("Resource not found: %1. Read sdrangel://docs for the available documentation").arg(uri));
2026-09-11 20:11:10 +01:00
}
content["mimeType"] = "text/markdown";
content["text"] = m_tools.docs().text(*doc);
}
else if (uri.startsWith("sdrangel://deviceset/"))
{
bool ok;
int index = uri.mid(QString("sdrangel://deviceset/").size()).toInt(&ok);
if (!ok) {
throw MCPError(InvalidParams, QString("Invalid device set index in %1").arg(uri));
}
content["mimeType"] = "application/json";
content["text"] = QString(QJsonDocument(m_tools.getDeviceSet(index)).toJson(QJsonDocument::Compact));
}
else
{
throw MCPError(ResourceNotFound, QString("Resource not found: %1").arg(uri));
2026-09-11 20:11:10 +01:00
}
QJsonArray contents;
contents.append(content);
QJsonObject result;
result["contents"] = contents;
return result;
}
QJsonObject MCPProtocol::promptMessage(const QString& text)
{
QJsonObject message;
message["role"] = "user";
message["content"] = textContent(text);
return message;
}
QJsonValue MCPProtocol::promptsList(const QJsonObject& params)
{
(void) params;
QJsonArray prompts;
{
QJsonObject arg1;
arg1["name"] = "signal";
arg1["description"] = "What to receive, e.g. ADS-B, AIS, APRS, broadcast FM, an airband frequency in MHz";
arg1["required"] = true;
QJsonObject arg2;
arg2["name"] = "device";
arg2["description"] = "Which SDR to use (hardware type or serial). Optional: the first available receiver is used otherwise";
arg2["required"] = false;
QJsonObject prompt;
prompt["name"] = "setup_receiver";
prompt["title"] = "Set up a receiver";
prompt["description"] = "Configure an SDR device and demodulator to receive a given signal or protocol";
prompt["arguments"] = QJsonArray({arg1, arg2});
prompts.append(prompt);
}
{
QJsonObject arg1;
arg1["name"] = "frequency";
arg1["description"] = "Centre frequency to record, e.g. 126 MHz";
arg1["required"] = true;
QJsonObject arg2;
arg2["name"] = "duration";
arg2["description"] = "How long to record for, e.g. 30 seconds";
arg2["required"] = false;
QJsonObject arg3;
arg3["name"] = "file";
arg3["description"] = "Output file path";
arg3["required"] = false;
QJsonObject prompt;
prompt["name"] = "record_iq";
prompt["title"] = "Record IQ data";
prompt["description"] = "Record baseband IQ samples from an SDR to a file";
prompt["arguments"] = QJsonArray({arg1, arg2, arg3});
prompts.append(prompt);
}
{
QJsonObject prompt;
prompt["name"] = "explain_configuration";
prompt["title"] = "Explain the current configuration";
prompt["description"] = "Describe what SDRangel is currently set up to do";
prompt["arguments"] = QJsonArray();
prompts.append(prompt);
}
QJsonObject result;
result["prompts"] = prompts;
return result;
}
QJsonValue MCPProtocol::promptsGet(const QJsonObject& params)
{
QString name = params["name"].toString();
QJsonObject args = params["arguments"].toObject();
QJsonObject result;
QJsonArray messages;
if (name == "setup_receiver")
{
QString signal = args["signal"].toString();
QString device = args["device"].toString();
result["description"] = QString("Set up SDRangel to receive %1").arg(signal);
messages.append(promptMessage(QString(
"Set up SDRangel to receive: %1.%2\n\n"
"1. If you do not already know the frequency and which demodulator this signal needs, call get_receiving_guide.\n"
"2. Call listen with that frequency and mode%3. It picks a device, sets a sample rate that suits the mode, adds the "
"demodulator, starts everything and reports the run state and signal level, so one call replaces the whole "
"device set, settings and channel sequence. Modes are bfm, wfm, nfm, am, ssb, usb, lsb, dab, adsb, ais and dsd, "
"or any channel type id from list_channel_types.\n"
"3. Read what listen returned. If it reports a note, act on it. Adjust with set_channel_settings if the signal needs "
"it (squelch, volume, rfBandwidth) and use get_channel_report to confirm.\n"
"4. Summarise what is now running, including anything that did not work.")
.arg(signal)
.arg(device.isEmpty() ? "" : QString(" Use this device: %1.").arg(device))
.arg(device.isEmpty() ? "" : QString(" and device \"%1\"").arg(device))));
}
else if (name == "record_iq")
{
QString frequency = args["frequency"].toString();
QString duration = args["duration"].toString();
QString file = args["file"].toString();
result["description"] = QString("Record IQ data at %1").arg(frequency);
messages.append(promptMessage(QString(
"Record baseband IQ data from an SDR centred on %1%2%3.\n\n"
"1. Call get_status. If a receiver is already running on that frequency, record from it. Otherwise get one running: "
"listen is the quickest way and confirms a signal is there, or use add_deviceset, set_device_settings and "
"start_device if you want no demodulator.\n"
"2. Call record_iq with the device set, duration and file name for a short capture, or start_iq_recording and "
"stop_iq_recording for a longer one. It adds and removes its own File Sink channel; get_server_status shows the "
"directory the files go to.\n"
"3. Report the files written with their size and duration. Warn the user if the capture will be large: the file grows "
"by roughly four bytes per sample, so a 2 MS/s capture uses about 8 MB per second.")
.arg(frequency)
.arg(duration.isEmpty() ? "" : QString(" for %1").arg(duration))
.arg(file.isEmpty() ? "" : QString(" to the file %1").arg(file))));
}
else if (name == "explain_configuration")
{
result["description"] = "Explain the current SDRangel configuration";
messages.append(promptMessage(
"Call get_status: it returns one line per device set and feature, with the device, its frequency, its run state and "
"the channels on it. That is usually enough. Only call get_device_settings, get_channel_settings or get_channel_report "
"for a device set the user asks about in more detail, since those return every key and are far larger.\n\n"
"Then explain in plain language what SDRangel is currently configured to do: which SDRs are in use, what frequencies they "
"are tuned to, which demodulators are running and what they are decoding, and whether the devices are running or stopped."));
}
else
{
throw MCPError(InvalidParams, QString("Prompt not found: %1").arg(name));
}
result["messages"] = messages;
return result;
}