From 5dc96403f35b28dc6933dd172a738ce3ba29a26a Mon Sep 17 00:00:00 2001 From: Alexandre Quessy Date: Sat, 23 May 2026 20:05:42 +0000 Subject: [PATCH 1/6] Add embedded MCP server exposing MapMap control over local HTTP Adds a native Model Context Protocol server (QHttpServer, JSON-RPC 2.0) running on the GUI thread, exposing sources, layers, playback, project I/O and state read-back to MCP clients at http://localhost:/mcp. The port is configurable via QSettings (mcpListeningPort, default 49452) and a new -m/--mcp-port CLI flag; 0 disables the server. Requires the QtHttpServer module (and its QtWebSockets dependency). Add port preference UI Adapt McpServer to Paint->Source and Mapping->Layer renames. Fix QHttpServer::listen() removal in Qt 6.11 by using QTcpServer::listen() with QHttpServer::bind(). Add MCP port spinbox to Preferences Controls tab (0 disables the server). --- src/app/main.cpp | 9 + src/control/McpServer.cpp | 538 +++++++++++++++++++++++++++++++++++ src/control/McpServer.h | 88 ++++++ src/control/control.pri | 6 +- src/core/MM.h | 4 + src/gui/MainWindow.cpp | 56 ++++ src/gui/MainWindow.h | 11 + src/gui/PreferenceDialog.cpp | 25 ++ src/gui/PreferenceDialog.h | 3 + src/src.pri | 1 + 10 files changed, 739 insertions(+), 2 deletions(-) create mode 100644 src/control/McpServer.cpp create mode 100644 src/control/McpServer.h diff --git a/src/app/main.cpp b/src/app/main.cpp index c5efc82ec..c7bfef177 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -105,6 +105,11 @@ int main(int argc, char *argv[]) "Use OSC port number .", "osc-port", ""); parser.addOption(oscPortOption); + // --mcp-port option + QCommandLineOption mcpPortOption(QStringList() << "m" << "mcp-port", + "Use MCP server port number (0 to disable).", "mcp-port", ""); + parser.addOption(mcpPortOption); + // --lang option QCommandLineOption localeOption(QStringList() << "l" << "lang", "Use language .", "lang", ""); @@ -213,6 +218,10 @@ int main(int argc, char *argv[]) if (parser.isSet(verboseOption)) win->setVerbose(true); + QString mcpPortValue = parser.value("mcp-port"); + if (mcpPortValue != "") + win->setMcpPort(mcpPortValue); + bool optionOk; qreal fps = parser.value("frame-rate").toDouble(&optionOk); if (optionOk) diff --git a/src/control/McpServer.cpp b/src/control/McpServer.cpp new file mode 100644 index 000000000..e4031ee3b --- /dev/null +++ b/src/control/McpServer.cpp @@ -0,0 +1,538 @@ +/* + * McpServer.cpp + * + * Embedded Model Context Protocol (MCP) server for MapMap. + * + * 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, either 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "McpServer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "MM.h" +#include "Element.h" +#include "Source.h" +#include "Layer.h" +#include "MappingManager.h" +#include "MainWindow.h" + +namespace mmp { + +McpServer::McpServer(MainWindow* mainWindow, QObject* parent) + : QObject(parent), _mainWindow(mainWindow), _httpServer(nullptr), _port(0) +{ +} + +McpServer::~McpServer() +{ + delete _httpServer; +} + +quint16 McpServer::start(quint16 port) +{ + // Recreate from scratch so re-binding to a new port is clean. + delete _httpServer; + _httpServer = new QHttpServer(this); + _port = 0; + + _httpServer->route("/mcp", [this](const QHttpServerRequest& request) -> QHttpServerResponse { + if (request.method() != QHttpServerRequest::Method::Post) + return QHttpServerResponse("text/plain", QByteArray("Method Not Allowed"), + QHttpServerResponse::StatusCode::MethodNotAllowed); + const QByteArray out = handleRpc(request.body()); + if (out.isEmpty()) // notification: acknowledge with no body + return QHttpServerResponse(QHttpServerResponse::StatusCode::Accepted); + return QHttpServerResponse("application/json", out, QHttpServerResponse::StatusCode::Ok); + }); + + // Localhost only — never expose the control surface on all interfaces. + auto *tcpServer = new QTcpServer(_httpServer); + if (!tcpServer->listen(QHostAddress::LocalHost, port) || !_httpServer->bind(tcpServer)) + { + qWarning() << "MCP server failed to bind to port" << port; + delete _httpServer; + _httpServer = nullptr; + return 0; + } + _port = tcpServer->serverPort(); + return _port; +} + +bool McpServer::isListening() const +{ + return _httpServer != nullptr && _port != 0; +} + +QByteArray McpServer::handleRpc(const QByteArray& body) +{ + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(body, &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) + { + const QJsonObject err = makeError(QJsonValue(), -32700, "Parse error"); + return QJsonDocument(err).toJson(QJsonDocument::Compact); + } + + const QJsonObject request = doc.object(); + // JSON-RPC notifications carry no "id" and expect no response. + if (!request.contains("id")) + return QByteArray(); + + const QJsonObject response = dispatch(request); + return QJsonDocument(response).toJson(QJsonDocument::Compact); +} + +QJsonObject McpServer::dispatch(const QJsonObject& request) +{ + const QJsonValue id = request.value("id"); + const QString method = request.value("method").toString(); + const QJsonObject params = request.value("params").toObject(); + + if (method == "initialize") return makeResult(id, handleInitialize(params)); + if (method == "ping") return makeResult(id, QJsonObject()); + if (method == "tools/list") return makeResult(id, handleToolsList()); + if (method == "tools/call") return makeResult(id, handleToolsCall(params)); + + return makeError(id, -32601, QString("Method not found: %1").arg(method)); +} + +QJsonObject McpServer::makeResult(const QJsonValue& id, const QJsonValue& result) +{ + QJsonObject o; + o["jsonrpc"] = "2.0"; + o["id"] = id; + o["result"] = result; + return o; +} + +QJsonObject McpServer::makeError(const QJsonValue& id, int code, const QString& message) +{ + QJsonObject error; + error["code"] = code; + error["message"] = message; + QJsonObject o; + o["jsonrpc"] = "2.0"; + o["id"] = id; + o["error"] = error; + return o; +} + +QJsonObject McpServer::handleInitialize(const QJsonObject& params) +{ + QString protocolVersion = params.value("protocolVersion").toString(); + if (protocolVersion.isEmpty()) + protocolVersion = "2025-06-18"; + + QJsonObject capabilities; + capabilities["tools"] = QJsonObject(); + + QJsonObject serverInfo; + serverInfo["name"] = "MapMap"; + serverInfo["version"] = MM::VERSION; + + QJsonObject result; + result["protocolVersion"] = protocolVersion; + result["capabilities"] = capabilities; + result["serverInfo"] = serverInfo; + return result; +} + +QJsonObject McpServer::handleToolsList() const +{ + QJsonObject result; + result["tools"] = toolDefinitions(); + return result; +} + +// --- Tool dispatch ----------------------------------------------------------- + +namespace { + +QJsonObject textResult(const QString& text, bool isError = false) +{ + QJsonObject content; + content["type"] = "text"; + content["text"] = text; + QJsonArray contents; + contents.append(content); + QJsonObject result; + result["content"] = contents; + result["isError"] = isError; + return result; +} + +QJsonObject jsonResult(const QJsonObject& data) +{ + QJsonObject result = textResult(QString::fromUtf8(QJsonDocument(data).toJson(QJsonDocument::Compact))); + result["structuredContent"] = data; + return result; +} + +} // namespace + +QJsonObject McpServer::handleToolsCall(const QJsonObject& params) +{ + const QString name = params.value("name").toString(); + const QJsonObject args = params.value("arguments").toObject(); + MappingManager& mm = _mainWindow->getMappingManager(); + + // ---- Playback / project ---- + if (name == "play") { _mainWindow->play(); return textResult("Playback started."); } + if (name == "pause") { _mainWindow->pause(); return textResult("Playback paused."); } + if (name == "rewind") { _mainWindow->rewind(); return textResult("Rewound to start."); } + if (name == "quit") + { + // Defer so we can still send the HTTP response (and not block on a dialog). + QMetaObject::invokeMethod(_mainWindow, "close", Qt::QueuedConnection); + return textResult("Quitting MapMap."); + } + if (name == "clear_project") { _mainWindow->clearProject(); return textResult("Project cleared."); } + if (name == "load_project") + { + const QString path = args.value("path").toString(); + if (path.isEmpty()) return textResult("Missing required argument 'path'.", true); + const bool ok = _mainWindow->loadFile(path); + return textResult(ok ? QString("Loaded project: %1").arg(path) + : QString("Failed to load project: %1").arg(path), !ok); + } + if (name == "save_project") + { + const QString path = args.value("path").toString(); + if (path.isEmpty()) return textResult("Missing required argument 'path'.", true); + const bool ok = _mainWindow->saveFile(path); + return textResult(ok ? QString("Saved project: %1").arg(path) + : QString("Failed to save project: %1").arg(path), !ok); + } + if (name == "set_fps") + { + const qreal fps = args.value("fps").toDouble(); + if (fps <= 0) return textResult("'fps' must be greater than 0.", true); + _mainWindow->setFramesPerSecond(fps); + return textResult(QString("Frame rate set to %1 fps.").arg(fps)); + } + + // ---- Sources ---- + if (name == "create_color_source") + { + const QColor color(args.value("color").toString()); + if (!color.isValid()) + return textResult("Invalid 'color' (use e.g. \"#ff0000\" or \"red\").", true); + if (!_mainWindow->addColorSource(color)) + return textResult("Failed to create color source.", true); + return jsonResult(sourceSummary(_mainWindow->getCurrentSourceId())); + } + if (name == "create_media_source") + { + const QString uri = args.value("uri").toString(); + if (uri.isEmpty()) return textResult("Missing required argument 'uri'.", true); + const bool isImage = args.value("is_image").toBool(false); + if (!_mainWindow->importMediaFile(uri, isImage, false)) + return textResult(QString("Failed to import media: %1").arg(uri), true); + return jsonResult(sourceSummary(_mainWindow->getCurrentSourceId())); + } + if (name == "delete_source") + { + const int id = static_cast(args.value("id").toInteger(0)); + if (mm.getSourceById(id).isNull()) return textResult(QString("No source with id %1.").arg(id), true); + _mainWindow->deleteSource(id); + return textResult(QString("Deleted source %1.").arg(id)); + } + + // ---- Layers ---- + if (name == "delete_layer") + { + const int id = static_cast(args.value("id").toInteger(0)); + if (mm.getLayerById(id).isNull()) return textResult(QString("No layer with id %1.").arg(id), true); + _mainWindow->deleteLayer(id); + return textResult(QString("Deleted layer %1.").arg(id)); + } + if (name == "duplicate_layer") + { + const int id = static_cast(args.value("id").toInteger(0)); + if (mm.getLayerById(id).isNull()) return textResult(QString("No layer with id %1.").arg(id), true); + _mainWindow->duplicateLayer(id); + return textResult(QString("Duplicated layer %1.").arg(id)); + } + if (name == "move_layer") + { + const int id = static_cast(args.value("id").toInteger(0)); + if (mm.getLayerById(id).isNull()) return textResult(QString("No layer with id %1.").arg(id), true); + const int index = static_cast(args.value("index").toInteger(0)); + _mainWindow->moveLayer(id, index); + return textResult(QString("Moved layer %1 to index %2.").arg(id).arg(index)); + } + if (name == "set_layer_visible" || name == "set_layer_solo" || name == "set_layer_locked") + { + const int id = static_cast(args.value("id").toInteger(0)); + if (mm.getLayerById(id).isNull()) return textResult(QString("No layer with id %1.").arg(id), true); + const bool value = args.value("value").toBool(); + if (name == "set_layer_visible") _mainWindow->setLayerVisible(id, value); + else if (name == "set_layer_solo") _mainWindow->setLayerSolo(id, value); + else _mainWindow->setLayerLocked(id, value); + return jsonResult(layerSummary(id)); + } + + // ---- Generic property set ---- + if (name == "set_property") + { + const QString kind = args.value("kind").toString(); + const int id = static_cast(args.value("id").toInteger(0)); + const QString property = args.value("property").toString(); + const QVariant value = args.value("value").toVariant(); + + QSharedPointer element; + if (kind == "source") element = mm.getSourceById(id); + else if (kind == "layer") element = mm.getLayerById(id); + else return textResult("'kind' must be \"source\" or \"layer\".", true); + + if (element.isNull()) return textResult(QString("No %1 with id %2.").arg(kind).arg(id), true); + if (property.isEmpty()) return textResult("Missing required argument 'property'.", true); + + if (!element->setProperty(property.toUtf8().constData(), value)) + return textResult(QString("Could not set property '%1' on %2 %3.").arg(property, kind).arg(id), true); + _mainWindow->updateCanvases(); + return textResult(QString("Set %1 %2 property '%3'.").arg(kind).arg(id).arg(property)); + } + + // ---- Read-back ---- + if (name == "get_state") + { + QJsonObject state; + state["playing"] = _mainWindow->isPlaying(); + state["fps"] = _mainWindow->framesPerSecond(); + state["nSources"] = mm.nSources(); + state["nLayers"] = mm.nLayers(); + state["currentSourceId"] = static_cast(_mainWindow->getCurrentSourceId()); + state["currentLayerId"] = static_cast(_mainWindow->getCurrentLayerId()); + return jsonResult(state); + } + if (name == "list_sources") + { + QJsonArray sources; + for (int i = 0; i < mm.nSources(); ++i) + { + Source::ptr source = mm.getSource(i); + if (!source.isNull()) sources.append(sourceSummary(source->getId())); + } + QJsonObject result; + result["sources"] = sources; + return jsonResult(result); + } + if (name == "list_layers") + { + QJsonArray layers; + for (int i = 0; i < mm.nLayers(); ++i) + { + Layer::ptr layer = mm.getLayer(i); + if (!layer.isNull()) layers.append(layerSummary(layer->getId())); + } + QJsonObject result; + result["layers"] = layers; + return jsonResult(result); + } + if (name == "get_source") + { + const int id = static_cast(args.value("id").toInteger(0)); + Source::ptr source = mm.getSourceById(id); + if (source.isNull()) return textResult(QString("No source with id %1.").arg(id), true); + return jsonResult(elementProperties(source.data())); + } + if (name == "get_layer") + { + const int id = static_cast(args.value("id").toInteger(0)); + Layer::ptr layer = mm.getLayerById(id); + if (layer.isNull()) return textResult(QString("No layer with id %1.").arg(id), true); + return jsonResult(elementProperties(layer.data())); + } + + return textResult(QString("Unknown tool: %1").arg(name), true); +} + +// --- Read-back helpers ------------------------------------------------------- + +QJsonObject McpServer::sourceSummary(int sourceId) const +{ + QJsonObject o; + Source::ptr source = _mainWindow->getMappingManager().getSourceById(sourceId); + if (source.isNull()) return o; + o["id"] = static_cast(source->getId()); + o["name"] = source->getName(); + o["opacity"] = source->getOpacity(); + o["locked"] = source->isLocked(); + switch (source->getSourceType()) + { + case Source::Video: o["type"] = "video"; break; + case Source::Image: o["type"] = "image"; break; + case Source::Color: o["type"] = "color"; break; + default: o["type"] = "unknown"; break; + } + return o; +} + +QJsonObject McpServer::layerSummary(int layerId) const +{ + QJsonObject o; + Layer::ptr layer = _mainWindow->getMappingManager().getLayerById(layerId); + if (layer.isNull()) return o; + o["id"] = static_cast(layer->getId()); + o["name"] = layer->getName(); + o["visible"] = layer->isVisible(); + o["solo"] = layer->isSolo(); + o["locked"] = layer->isLocked(); + o["depth"] = layer->getDepth(); + o["opacity"] = layer->getOpacity(); + o["sourceId"] = layer->getSource().isNull() ? -1 : static_cast(layer->getSourceId()); + return o; +} + +QJsonObject McpServer::elementProperties(Element* element) const +{ + QJsonObject o; + if (!element) return o; + const QMetaObject* meta = element->metaObject(); + for (int i = 0; i < meta->propertyCount(); ++i) + { + const QMetaProperty property = meta->property(i); + if (!property.isReadable()) continue; + const QVariant value = property.read(element); + if (value.typeId() == QMetaType::QColor) + o[QString::fromUtf8(property.name())] = qvariant_cast(value).name(); + else if (value.typeId() == QMetaType::QIcon) + continue; // not meaningfully serialisable + else + o[QString::fromUtf8(property.name())] = QJsonValue::fromVariant(value); + } + return o; +} + +// --- Tool catalogue ---------------------------------------------------------- + +QJsonArray McpServer::toolDefinitions() const +{ + auto tool = [](const QString& name, const QString& description, const QJsonObject& properties, + const QJsonArray& required) { + QJsonObject schema; + schema["type"] = "object"; + schema["properties"] = properties; + if (!required.isEmpty()) schema["required"] = required; + QJsonObject t; + t["name"] = name; + t["description"] = description; + t["inputSchema"] = schema; + return t; + }; + auto prop = [](const QString& type, const QString& description) { + QJsonObject p; + p["type"] = type; + p["description"] = description; + return p; + }; + + QJsonArray tools; + + tools.append(tool("play", "Start playback of all sources.", QJsonObject(), QJsonArray())); + tools.append(tool("pause", "Pause playback.", QJsonObject(), QJsonArray())); + tools.append(tool("rewind", "Rewind all sources to the start.", QJsonObject(), QJsonArray())); + tools.append(tool("quit", "Quit the MapMap application.", QJsonObject(), QJsonArray())); + tools.append(tool("clear_project", "Clear all sources and layers from the project.", + QJsonObject(), QJsonArray())); + + tools.append(tool("load_project", "Load a MapMap project from a file path.", + QJsonObject{{"path", prop("string", "Absolute path to the .mmp project file.")}}, + QJsonArray{"path"})); + tools.append(tool("save_project", "Save the current project to a file path.", + QJsonObject{{"path", prop("string", "Absolute path to write the .mmp project file.")}}, + QJsonArray{"path"})); + tools.append(tool("set_fps", "Set the playback frame rate.", + QJsonObject{{"fps", prop("number", "Frames per second (> 0).")}}, + QJsonArray{"fps"})); + + tools.append(tool("create_color_source", "Create a solid-colour source. Returns the new source.", + QJsonObject{{"color", prop("string", "Colour as \"#rrggbb\" or a named colour.")}}, + QJsonArray{"color"})); + tools.append(tool("create_media_source", + "Import an image or video file as a source. Returns the new source.", + QJsonObject{ + {"uri", prop("string", "Absolute path to the media file.")}, + {"is_image", prop("boolean", "True for an image, false for a video (default false).")} + }, + QJsonArray{"uri"})); + tools.append(tool("delete_source", "Delete a source and its associated layers.", + QJsonObject{{"id", prop("integer", "Source id.")}}, QJsonArray{"id"})); + + tools.append(tool("delete_layer", "Delete a layer.", + QJsonObject{{"id", prop("integer", "Layer id.")}}, QJsonArray{"id"})); + tools.append(tool("duplicate_layer", "Duplicate a layer.", + QJsonObject{{"id", prop("integer", "Layer id.")}}, QJsonArray{"id"})); + tools.append(tool("move_layer", "Move a layer to a new stacking index.", + QJsonObject{ + {"id", prop("integer", "Layer id.")}, + {"index", prop("integer", "Target index (0 = bottom).")} + }, + QJsonArray{"id", "index"})); + tools.append(tool("set_layer_visible", "Show or hide a layer. Returns the layer.", + QJsonObject{ + {"id", prop("integer", "Layer id.")}, + {"value", prop("boolean", "Visible if true.")} + }, + QJsonArray{"id", "value"})); + tools.append(tool("set_layer_solo", "Set a layer's solo state. Returns the layer.", + QJsonObject{ + {"id", prop("integer", "Layer id.")}, + {"value", prop("boolean", "Solo if true.")} + }, + QJsonArray{"id", "value"})); + tools.append(tool("set_layer_locked", "Lock or unlock a layer. Returns the layer.", + QJsonObject{ + {"id", prop("integer", "Layer id.")}, + {"value", prop("boolean", "Locked if true.")} + }, + QJsonArray{"id", "value"})); + + tools.append(tool("set_property", + "Set an arbitrary property on a source or layer (e.g. name, opacity, color, uri).", + QJsonObject{ + {"kind", prop("string", "\"source\" or \"layer\".")}, + {"id", prop("integer", "Element id.")}, + {"property", prop("string", "Property name.")}, + {"value", QJsonObject{{"description", "Property value (type depends on the property)."}}} + }, + QJsonArray{"kind", "id", "property", "value"})); + + tools.append(tool("get_state", + "Get overall state: playing, fps, counts and current selection.", + QJsonObject(), QJsonArray())); + tools.append(tool("list_sources", "List all sources with their summary fields.", + QJsonObject(), QJsonArray())); + tools.append(tool("list_layers", "List all layers with their summary fields.", + QJsonObject(), QJsonArray())); + tools.append(tool("get_source", "Get all properties of a source.", + QJsonObject{{"id", prop("integer", "Source id.")}}, QJsonArray{"id"})); + tools.append(tool("get_layer", "Get all properties of a layer.", + QJsonObject{{"id", prop("integer", "Layer id.")}}, QJsonArray{"id"})); + + return tools; +} + +} diff --git a/src/control/McpServer.h b/src/control/McpServer.h new file mode 100644 index 000000000..ee518ecd3 --- /dev/null +++ b/src/control/McpServer.h @@ -0,0 +1,88 @@ +/* + * McpServer.h + * + * Embedded Model Context Protocol (MCP) server for MapMap. + * + * Exposes MapMap's control surface (sources, layers, playback, project I/O) + * to MCP clients over a local HTTP endpoint speaking JSON-RPC 2.0. Runs on the + * GUI thread's event loop, so tool handlers call into MainWindow directly. + * + * 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, either 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 for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +QT_BEGIN_NAMESPACE +class QHttpServer; +QT_END_NAMESPACE + +namespace mmp { + +class MainWindow; + +/** + * Embedded MCP server exposing MapMap over local HTTP (JSON-RPC 2.0). + * The endpoint is POST http://localhost:/mcp. + */ +class McpServer : public QObject { + Q_OBJECT + +public: + explicit McpServer(MainWindow* mainWindow, QObject* parent = nullptr); + ~McpServer(); + + /// Starts listening on localhost:. Returns the bound port, or 0 on failure. + quint16 start(quint16 port); + + /// True when the HTTP server is bound and listening. + bool isListening() const; + + /// Currently bound port (0 if not listening). + quint16 port() const { return _port; } + +private: + // --- JSON-RPC plumbing --- + // Returns the response body, or an empty array for notifications (no reply). + QByteArray handleRpc(const QByteArray& body); + QJsonObject dispatch(const QJsonObject& request); + + static QJsonObject makeResult(const QJsonValue& id, const QJsonValue& result); + static QJsonObject makeError(const QJsonValue& id, int code, const QString& message); + + // --- MCP methods --- + QJsonObject handleInitialize(const QJsonObject& params); + QJsonObject handleToolsList() const; + // Routes a tools/call by name; sets isError on failure. Returns MCP result object. + QJsonObject handleToolsCall(const QJsonObject& params); + + // --- Tool definitions (for tools/list) --- + QJsonArray toolDefinitions() const; + + // --- Read-back helpers --- + QJsonObject sourceSummary(int sourceId) const; + QJsonObject layerSummary(int layerId) const; + // Full Q_PROPERTY dump for an Element (Source or Layer). + QJsonObject elementProperties(class Element* element) const; + + MainWindow* _mainWindow; + QHttpServer* _httpServer; + quint16 _port; +}; + +} diff --git a/src/control/control.pri b/src/control/control.pri index 4f2455eda..bd304acdf 100644 --- a/src/control/control.pri +++ b/src/control/control.pri @@ -1,9 +1,11 @@ include(../src.pri) HEADERS += $$PWD/ConcurrentQueue.h \ - $$PWD/OscInterface.h + $$PWD/OscInterface.h \ + $$PWD/McpServer.h -SOURCES += $$PWD/OscInterface.cpp +SOURCES += $$PWD/OscInterface.cpp \ + $$PWD/McpServer.cpp # OSC support: INCLUDEPATH += $$PWD/qosc diff --git a/src/core/MM.h b/src/core/MM.h index a9fec16a2..d0a1d5cfd 100644 --- a/src/core/MM.h +++ b/src/core/MM.h @@ -77,6 +77,10 @@ class MM // OSC static const int DEFAULT_OSC_PORT = 12345; + // MCP (Model Context Protocol) server. Uses a port in the IANA + // dynamic/private range (49152-65535) to minimise collisions. + static const int DEFAULT_MCP_PORT = 49452; + // Default values static const bool DISPLAY_TEST_SIGNAL = false; static const bool DISPLAY_OUTPUT_WINDOW = false; diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index b47d3cef9..f49cf6b32 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -89,6 +89,9 @@ MainWindow::MainWindow() // Start osc. startOscReceiver(); + // Start MCP server. + startMcpServer(); + // Defaults. setWindowIcon(QIcon(":/mapmap-logo")); setCurrentFile(""); @@ -2527,6 +2530,7 @@ void MainWindow::readSettings() displayControlsAction->setChecked(settings.value("displayControls", MM::DISPLAY_CONTROLS).toBool()); outputWindow->setCanvasDisplayCrosshair(settings.value("displayControls", MM::DISPLAY_CONTROLS).toBool()); oscListeningPort = settings.value("oscListeningPort", MM::DEFAULT_OSC_PORT).toInt(); + mcpListeningPort = settings.value("mcpListeningPort", MM::DEFAULT_MCP_PORT).toInt(); // Update Recent files and video updateRecentFileActions(); @@ -2560,6 +2564,7 @@ void MainWindow::writeSettings() settings.setValue("displayControls", displayControlsAction->isChecked()); settings.setValue("displayAllControls", displaySourceControlsAction->isChecked()); settings.setValue("oscListeningPort", oscListeningPort); + settings.setValue("mcpListeningPort", mcpListeningPort); settings.setValue("displayUndoStack", displayUndoHistoryAction->isChecked()); settings.setValue("zoomToolBar", displayZoomToolAction->isChecked()); settings.setValue("showMenuBar", showMenuBarAction->isChecked()); @@ -3736,6 +3741,57 @@ bool MainWindow::setOscPort(QString portNumber) return true; } +void MainWindow::startMcpServer() +{ + if (mcp_server.isNull()) + mcp_server.reset(new McpServer(this)); + + if (mcpListeningPort == 0) + { + QMessageLogger(__FILE__, __LINE__, 0).info() << "MCP server disabled (port 0)."; + return; + } + + quint16 boundPort = mcp_server->start(static_cast(mcpListeningPort)); + if (boundPort != 0) + QMessageLogger(__FILE__, __LINE__, 0).info() + << "MCP server listening on http://localhost:" << boundPort << "/mcp"; + else + qWarning() << "MCP server could not start on port" << mcpListeningPort; +} + +bool MainWindow::setMcpPort(int port) +{ + if (port != 0 && (port <= 1023 || port > 65535)) + { + qWarning() << "MCP port is out of range: " << port << Qt::endl; + return false; + } + mcpListeningPort = port; + startMcpServer(); + return true; +} + +int MainWindow::getMcpPort() const +{ + return mcpListeningPort; +} + +bool MainWindow::setMcpPort(QString portNumber) +{ + bool ok; + int port = portNumber.toInt(&ok); + if (ok) + { + return setMcpPort(port); + } + else + { + qWarning() << "MCP port is not a number: " << portNumber << Qt::endl; + return false; + } +} + void MainWindow::pollOscInterface() { // FIXME: we should now use its QObject signals instead of polling it diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 556293dd9..284a03ea1 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -37,6 +37,7 @@ #include "MapperGLCanvas.h" #include "MapperGLCanvasToolbar.h" #include "OscInterface.h" +#include "McpServer.h" #include "OutputGLWindow.h" #include "ConsoleWindow.h" @@ -302,6 +303,9 @@ public slots: // OSC. void startOscReceiver(); + // MCP server. + void startMcpServer(); + // Actions-related. bool okToContinue(); @@ -518,6 +522,10 @@ public slots: int oscListeningPort; QTimer *osc_timer; + // MCP server. + QScopedPointer mcp_server; + int mcpListeningPort; + // View. // The view counterpart of Mappings. @@ -624,6 +632,9 @@ public slots: bool setOscPort(int portNumber); int getOscPort() const; void setVerbose(bool verbose); + bool setMcpPort(QString portNumber); + bool setMcpPort(int portNumber); + int getMcpPort() const; void setOutputWindowFullScreen(bool enable); public: diff --git a/src/gui/PreferenceDialog.cpp b/src/gui/PreferenceDialog.cpp index 155c0d70a..efbac60d9 100644 --- a/src/gui/PreferenceDialog.cpp +++ b/src/gui/PreferenceDialog.cpp @@ -122,6 +122,8 @@ bool PreferenceDialog::loadSettings() // Allow OSC message with same media source _oscSameMediaSourceBox->setChecked(settings.value("oscSameMediaSource", MM::OSC_SAME_MEDIA_SOURCE).toBool()); + // MCP port + _mcpPortNumber->setValue(settings.value("mcpListeningPort", MM::DEFAULT_MCP_PORT).toInt()); // Play in loop _playInLoopBox->setChecked(settings.value("playInLoop", MM::PLAY_IN_LOOP).toBool()); @@ -156,6 +158,9 @@ void PreferenceDialog::applySettings() settings.setValue("language", _languageBox->currentData()); // Allow OSC message with same media source settings.setValue("oscSameMediaSource", _oscSameMediaSourceBox->isChecked()); + // MCP port + settings.setValue("mcpListeningPort", _mcpPortNumber->value()); + mainWindow->setMcpPort(_mcpPortNumber->value()); // Play in loop settings.setValue("playInLoop", _playInLoopBox->isChecked()); } @@ -373,6 +378,26 @@ void PreferenceDialog::createControlsPage() _controlsPage->addTab(_oscWidget, tr("OSC Setup")); + // MCP Tab + _mcpWidget = new QWidget; + + _mcpPortNumber = new QSpinBox; + _mcpPortNumber->setRange(0, 65534); + _mcpPortNumber->setFixedWidth(120); + _mcpPortNumber->setSpecialValueText(tr("Disabled")); + + QFormLayout *mcpPortForm = new QFormLayout; + mcpPortForm->setFieldGrowthPolicy(QFormLayout::FieldsStayAtSizeHint); + mcpPortForm->addRow(tr("MCP port (0 to disable)"), _mcpPortNumber); + + QVBoxLayout *mcpLayout = new QVBoxLayout; + mcpLayout->addLayout(mcpPortForm); + mcpLayout->addStretch(); + + _mcpWidget->setLayout(mcpLayout); + + _controlsPage->addTab(_mcpWidget, tr("MCP Setup")); + refreshCurrentIP(); } diff --git a/src/gui/PreferenceDialog.h b/src/gui/PreferenceDialog.h index 1c40b1677..862fd5c6d 100644 --- a/src/gui/PreferenceDialog.h +++ b/src/gui/PreferenceDialog.h @@ -109,6 +109,9 @@ private slots: QSpinBox *_listenPortNumber; QPushButton *_ipRefreshButton; QCheckBox *_oscSameMediaSourceBox; + // MCP + QWidget *_mcpWidget; + QSpinBox *_mcpPortNumber; // Advanced widgets // Playback diff --git a/src/src.pri b/src/src.pri index 963693758..c88d75975 100644 --- a/src/src.pri +++ b/src/src.pri @@ -6,6 +6,7 @@ QT += network QT += multimedia QT += multimediawidgets QT += widgets +QT += httpserver #Includes common configuration for all subdirectory .pro files. INCLUDEPATH += $$PWD/core \ From a6a3027c22c3135344a3eebb506769835e06cfab Mon Sep 17 00:00:00 2001 From: Alexandre Quessy Date: Sat, 23 May 2026 22:52:46 -0400 Subject: [PATCH 2/6] Make MCP server build optional when qthttpserver is unavailable Use qtHaveModule(httpserver) to conditionally enable MCP support, guarded by HAVE_MCP preprocessor define throughout the codebase. This fixes CI builds on platforms without the Qt HttpServer module. --- src/app/main.cpp | 4 ++++ src/control/control.pri | 11 +++++++---- src/gui/MainWindow.cpp | 6 ++++++ src/gui/MainWindow.h | 8 ++++++++ src/gui/PreferenceDialog.cpp | 6 ++++++ src/gui/PreferenceDialog.h | 2 ++ src/src.pri | 8 +++++++- 7 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/app/main.cpp b/src/app/main.cpp index c7bfef177..bcd415ada 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -105,10 +105,12 @@ int main(int argc, char *argv[]) "Use OSC port number .", "osc-port", ""); parser.addOption(oscPortOption); +#ifdef HAVE_MCP // --mcp-port option QCommandLineOption mcpPortOption(QStringList() << "m" << "mcp-port", "Use MCP server port number (0 to disable).", "mcp-port", ""); parser.addOption(mcpPortOption); +#endif // --lang option QCommandLineOption localeOption(QStringList() << "l" << "lang", @@ -218,9 +220,11 @@ int main(int argc, char *argv[]) if (parser.isSet(verboseOption)) win->setVerbose(true); +#ifdef HAVE_MCP QString mcpPortValue = parser.value("mcp-port"); if (mcpPortValue != "") win->setMcpPort(mcpPortValue); +#endif bool optionOk; qreal fps = parser.value("frame-rate").toDouble(&optionOk); diff --git a/src/control/control.pri b/src/control/control.pri index bd304acdf..7a8383137 100644 --- a/src/control/control.pri +++ b/src/control/control.pri @@ -1,11 +1,14 @@ include(../src.pri) HEADERS += $$PWD/ConcurrentQueue.h \ - $$PWD/OscInterface.h \ - $$PWD/McpServer.h + $$PWD/OscInterface.h -SOURCES += $$PWD/OscInterface.cpp \ - $$PWD/McpServer.cpp +SOURCES += $$PWD/OscInterface.cpp + +contains(DEFINES, HAVE_MCP) { + HEADERS += $$PWD/McpServer.h + SOURCES += $$PWD/McpServer.cpp +} # OSC support: INCLUDEPATH += $$PWD/qosc diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index f49cf6b32..5e632b942 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -89,8 +89,10 @@ MainWindow::MainWindow() // Start osc. startOscReceiver(); +#ifdef HAVE_MCP // Start MCP server. startMcpServer(); +#endif // Defaults. setWindowIcon(QIcon(":/mapmap-logo")); @@ -2530,7 +2532,9 @@ void MainWindow::readSettings() displayControlsAction->setChecked(settings.value("displayControls", MM::DISPLAY_CONTROLS).toBool()); outputWindow->setCanvasDisplayCrosshair(settings.value("displayControls", MM::DISPLAY_CONTROLS).toBool()); oscListeningPort = settings.value("oscListeningPort", MM::DEFAULT_OSC_PORT).toInt(); +#ifdef HAVE_MCP mcpListeningPort = settings.value("mcpListeningPort", MM::DEFAULT_MCP_PORT).toInt(); +#endif // Update Recent files and video updateRecentFileActions(); @@ -3741,6 +3745,7 @@ bool MainWindow::setOscPort(QString portNumber) return true; } +#ifdef HAVE_MCP void MainWindow::startMcpServer() { if (mcp_server.isNull()) @@ -3791,6 +3796,7 @@ bool MainWindow::setMcpPort(QString portNumber) return false; } } +#endif // HAVE_MCP void MainWindow::pollOscInterface() { diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 284a03ea1..8f06c45c5 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -37,7 +37,9 @@ #include "MapperGLCanvas.h" #include "MapperGLCanvasToolbar.h" #include "OscInterface.h" +#ifdef HAVE_MCP #include "McpServer.h" +#endif #include "OutputGLWindow.h" #include "ConsoleWindow.h" @@ -303,8 +305,10 @@ public slots: // OSC. void startOscReceiver(); +#ifdef HAVE_MCP // MCP server. void startMcpServer(); +#endif // Actions-related. bool okToContinue(); @@ -522,9 +526,11 @@ public slots: int oscListeningPort; QTimer *osc_timer; +#ifdef HAVE_MCP // MCP server. QScopedPointer mcp_server; int mcpListeningPort; +#endif // View. @@ -632,9 +638,11 @@ public slots: bool setOscPort(int portNumber); int getOscPort() const; void setVerbose(bool verbose); +#ifdef HAVE_MCP bool setMcpPort(QString portNumber); bool setMcpPort(int portNumber); int getMcpPort() const; +#endif void setOutputWindowFullScreen(bool enable); public: diff --git a/src/gui/PreferenceDialog.cpp b/src/gui/PreferenceDialog.cpp index efbac60d9..ce330dfcf 100644 --- a/src/gui/PreferenceDialog.cpp +++ b/src/gui/PreferenceDialog.cpp @@ -122,8 +122,10 @@ bool PreferenceDialog::loadSettings() // Allow OSC message with same media source _oscSameMediaSourceBox->setChecked(settings.value("oscSameMediaSource", MM::OSC_SAME_MEDIA_SOURCE).toBool()); +#ifdef HAVE_MCP // MCP port _mcpPortNumber->setValue(settings.value("mcpListeningPort", MM::DEFAULT_MCP_PORT).toInt()); +#endif // Play in loop _playInLoopBox->setChecked(settings.value("playInLoop", MM::PLAY_IN_LOOP).toBool()); @@ -158,9 +160,11 @@ void PreferenceDialog::applySettings() settings.setValue("language", _languageBox->currentData()); // Allow OSC message with same media source settings.setValue("oscSameMediaSource", _oscSameMediaSourceBox->isChecked()); +#ifdef HAVE_MCP // MCP port settings.setValue("mcpListeningPort", _mcpPortNumber->value()); mainWindow->setMcpPort(_mcpPortNumber->value()); +#endif // Play in loop settings.setValue("playInLoop", _playInLoopBox->isChecked()); } @@ -378,6 +382,7 @@ void PreferenceDialog::createControlsPage() _controlsPage->addTab(_oscWidget, tr("OSC Setup")); +#ifdef HAVE_MCP // MCP Tab _mcpWidget = new QWidget; @@ -397,6 +402,7 @@ void PreferenceDialog::createControlsPage() _mcpWidget->setLayout(mcpLayout); _controlsPage->addTab(_mcpWidget, tr("MCP Setup")); +#endif refreshCurrentIP(); } diff --git a/src/gui/PreferenceDialog.h b/src/gui/PreferenceDialog.h index 862fd5c6d..99641fe22 100644 --- a/src/gui/PreferenceDialog.h +++ b/src/gui/PreferenceDialog.h @@ -109,9 +109,11 @@ private slots: QSpinBox *_listenPortNumber; QPushButton *_ipRefreshButton; QCheckBox *_oscSameMediaSourceBox; +#ifdef HAVE_MCP // MCP QWidget *_mcpWidget; QSpinBox *_mcpPortNumber; +#endif // Advanced widgets // Playback diff --git a/src/src.pri b/src/src.pri index c88d75975..248a3cee2 100644 --- a/src/src.pri +++ b/src/src.pri @@ -6,7 +6,13 @@ QT += network QT += multimedia QT += multimediawidgets QT += widgets -QT += httpserver +qtHaveModule(httpserver) { + QT += httpserver + DEFINES += HAVE_MCP + message("Qt HttpServer module found — MCP server enabled") +} else { + message("Qt HttpServer module not found — MCP server disabled") +} #Includes common configuration for all subdirectory .pro files. INCLUDEPATH += $$PWD/core \ From 6afb5d7349ca95b1748f9c51b53c73f96169ab9a Mon Sep 17 00:00:00 2001 From: Alexandre Quessy Date: Tue, 26 May 2026 00:36:26 -0400 Subject: [PATCH 3/6] Add create_layer tool to MCP server for triangle, quad and ellipse shapes Move addMesh/addTriangle/addEllipse from private to public slots in MainWindow so the MCP server can call them directly. --- src/control/McpServer.cpp | 26 ++++++++++++++++++++++++++ src/gui/MainWindow.h | 8 +++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/control/McpServer.cpp b/src/control/McpServer.cpp index e4031ee3b..5adea6ebe 100644 --- a/src/control/McpServer.cpp +++ b/src/control/McpServer.cpp @@ -251,6 +251,25 @@ QJsonObject McpServer::handleToolsCall(const QJsonObject& params) return textResult(QString("Failed to import media: %1").arg(uri), true); return jsonResult(sourceSummary(_mainWindow->getCurrentSourceId())); } + if (name == "create_layer") + { + const int sourceId = static_cast(args.value("source_id").toInteger(0)); + if (mm.getSourceById(sourceId).isNull()) + return textResult(QString("No source with id %1.").arg(sourceId), true); + const QString shape = args.value("shape").toString("quad").toLower(); + + // Select the source so addTriangle/addMesh/addEllipse use it. + _mainWindow->setCurrentSource(sourceId); + + if (shape == "triangle") _mainWindow->addTriangle(); + else if (shape == "quad") _mainWindow->addMesh(); + else if (shape == "ellipse") _mainWindow->addEllipse(); + else return textResult(QString("Unknown shape '%1'. Use triangle, quad or ellipse.").arg(shape), true); + + const int layerId = static_cast(_mainWindow->getCurrentLayerId()); + if (layerId == 0) return textResult("Failed to create layer.", true); + return jsonResult(layerSummary(layerId)); + } if (name == "delete_source") { const int id = static_cast(args.value("id").toInteger(0)); @@ -478,6 +497,13 @@ QJsonArray McpServer::toolDefinitions() const {"is_image", prop("boolean", "True for an image, false for a video (default false).")} }, QJsonArray{"uri"})); + tools.append(tool("create_layer", + "Create a layer with a given shape for a source. Returns the new layer.", + QJsonObject{ + {"source_id", prop("integer", "Source id to use.")}, + {"shape", prop("string", "Shape type: \"triangle\", \"quad\" or \"ellipse\" (default \"quad\").")} + }, + QJsonArray{"source_id", "shape"})); tools.append(tool("delete_source", "Delete a source and its associated layers.", QJsonObject{{"id", prop("integer", "Source id.")}}, QJsonArray{"id"})); diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 8f06c45c5..f590589f8 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -140,9 +140,6 @@ private slots: void layerPropertyChanged(uid id, QString propertyName, QVariant value); void sourcePropertyChanged(uid id, QString propertyName, QVariant value); - void addMesh(); - void addTriangle(); - void addEllipse(); // Other. void windowModified(); @@ -174,6 +171,11 @@ private slots: public slots: + // Layer creation. + void addMesh(); + void addTriangle(); + void addEllipse(); + // CRUD. /// Clears all mappings and sources. From f8c19c4e3b2eea112aa67fd7678218afbf9010f0 Mon Sep 17 00:00:00 2001 From: Alexandre Quessy Date: Tue, 26 May 2026 00:38:18 -0400 Subject: [PATCH 4/6] Add Claude Code skill for controlling MapMap via MCP --- .claude/skills/mapmap-mcp.md | 92 ++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .claude/skills/mapmap-mcp.md diff --git a/.claude/skills/mapmap-mcp.md b/.claude/skills/mapmap-mcp.md new file mode 100644 index 000000000..1da64556a --- /dev/null +++ b/.claude/skills/mapmap-mcp.md @@ -0,0 +1,92 @@ +--- +name: mapmap-mcp +description: Control MapMap via its embedded MCP server over local HTTP. Use this skill whenever the user asks to create, modify, inspect, or control sources, layers, playback, or project state in the running MapMap instance. +trigger: When the user wants to interact with the running MapMap application - creating sources/layers, controlling playback, querying state, modifying properties, loading/saving projects. +--- + +# MapMap MCP Control + +MapMap exposes a JSON-RPC 2.0 MCP server at `http://localhost:49452/mcp` (POST only). + +## How to call + +```bash +curl -s -X POST http://localhost:49452/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"TOOL_NAME","arguments":{...}}}' \ + | python3 -m json.tool +``` + +Increment the `id` for each call in a session. Always pipe through `python3 -m json.tool` for readable output. + +### First call: initialize + +Before any tool calls, send an initialize request: + +```bash +curl -s -X POST http://localhost:49452/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"claude","version":"1.0"}}}' +``` + +## Available tools + +### Playback & Project +| Tool | Args | Description | +|------|------|-------------| +| `play` | — | Start playback | +| `pause` | — | Pause playback | +| `rewind` | — | Rewind to start | +| `quit` | — | Quit MapMap | +| `clear_project` | — | Clear all sources and layers | +| `load_project` | `path` (string, required) | Load a .mmp project file | +| `save_project` | `path` (string, required) | Save project to file | +| `set_fps` | `fps` (number, required, > 0) | Set playback frame rate | + +### Sources +| Tool | Args | Description | +|------|------|-------------| +| `create_color_source` | `color` (string, required: "#rrggbb" or name) | Create solid-color source | +| `create_media_source` | `uri` (string, required), `is_image` (bool, default false) | Import image or video | +| `delete_source` | `id` (int, required) | Delete source and its layers | + +### Layers +| Tool | Args | Description | +|------|------|-------------| +| `create_layer` | `source_id` (int, required), `shape` (string, required: "triangle", "quad", or "ellipse") | Create a layer with a shape for a source | +| `delete_layer` | `id` (int, required) | Delete a layer | +| `duplicate_layer` | `id` (int, required) | Duplicate a layer | +| `move_layer` | `id` (int, required), `index` (int, required, 0=bottom) | Move layer in stack | +| `set_layer_visible` | `id` (int), `value` (bool) | Show/hide layer | +| `set_layer_solo` | `id` (int), `value` (bool) | Solo a layer | +| `set_layer_locked` | `id` (int), `value` (bool) | Lock/unlock layer | + +### Properties +| Tool | Args | Description | +|------|------|-------------| +| `set_property` | `kind` ("source"/"layer"), `id` (int), `property` (string), `value` (any) | Set arbitrary property | + +Common properties: +- **Source (color):** `color` ("#rrggbb"), `name`, `opacity` (0.0-1.0), `locked` +- **Layer:** `name`, `opacity`, `visible`, `solo`, `locked`, `depth` + +### Introspection +| Tool | Args | Description | +|------|------|-------------| +| `get_state` | — | Get playing, fps, counts, current selection | +| `list_sources` | — | List all sources | +| `list_layers` | — | List all layers | +| `get_source` | `id` (int) | Get all properties of a source | +| `get_layer` | `id` (int) | Get all properties of a layer | + +## Typical workflow + +1. Create a source (`create_color_source` or `create_media_source`) +2. Create a layer for it (`create_layer` with `source_id` from step 1) +3. Adjust properties as needed (`set_property`, `set_layer_visible`, etc.) +4. Control playback (`play`, `pause`, `rewind`) + +## Response format + +Success returns `{"isError": false, "content": [...]}` with optional `structuredContent`. +Errors return `{"isError": true, "content": [{"type":"text","text":"error message"}]}`. From 90ff489c217da1f2d96c1a440b4fdcbd99795ae0 Mon Sep 17 00:00:00 2001 From: Alexandre Quessy Date: Tue, 26 May 2026 00:47:32 -0400 Subject: [PATCH 5/6] Add set_vertices MCP tool and update skill doc Allows setting the output shape vertices of a layer via MCP. Documents vertex ordering and the new tool in the skill file. --- .claude/skills/mapmap-mcp.md | 15 +++++++++++++-- src/control/McpServer.cpp | 30 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.claude/skills/mapmap-mcp.md b/.claude/skills/mapmap-mcp.md index 1da64556a..4f6b4789b 100644 --- a/.claude/skills/mapmap-mcp.md +++ b/.claude/skills/mapmap-mcp.md @@ -61,6 +61,16 @@ curl -s -X POST http://localhost:49452/mcp \ | `set_layer_solo` | `id` (int), `value` (bool) | Solo a layer | | `set_layer_locked` | `id` (int), `value` (bool) | Lock/unlock layer | +### Geometry +| Tool | Args | Description | +|------|------|-------------| +| `set_vertices` | `id` (int, layer id), `vertices` (array of `{x, y}`) | Set the output vertices of a layer's shape | + +Vertex order is **clockwise from top-left**: top-left, top-right, bottom-right, bottom-left. +- Quad: 4 vertices +- Triangle: 3 vertices (bottom-left, bottom-right, top-center) +- Ellipse: 5 vertices (left, top, right, bottom, rotation-handle) + ### Properties | Tool | Args | Description | |------|------|-------------| @@ -83,8 +93,9 @@ Common properties: 1. Create a source (`create_color_source` or `create_media_source`) 2. Create a layer for it (`create_layer` with `source_id` from step 1) -3. Adjust properties as needed (`set_property`, `set_layer_visible`, etc.) -4. Control playback (`play`, `pause`, `rewind`) +3. Position the layer (`set_vertices` with the desired corner positions) +4. Adjust properties as needed (`set_property`, `set_layer_visible`, etc.) +5. Control playback (`play`, `pause`, `rewind`) ## Response format diff --git a/src/control/McpServer.cpp b/src/control/McpServer.cpp index 5adea6ebe..1accace41 100644 --- a/src/control/McpServer.cpp +++ b/src/control/McpServer.cpp @@ -34,6 +34,7 @@ #include "Element.h" #include "Source.h" #include "Layer.h" +#include "Shape.h" #include "MappingManager.h" #include "MainWindow.h" @@ -312,6 +313,28 @@ QJsonObject McpServer::handleToolsCall(const QJsonObject& params) return jsonResult(layerSummary(id)); } + if (name == "set_vertices") + { + const int id = static_cast(args.value("id").toInteger(0)); + Layer::ptr layer = mm.getLayerById(id); + if (layer.isNull()) return textResult(QString("No layer with id %1.").arg(id), true); + const QJsonArray verts = args.value("vertices").toArray(); + if (verts.isEmpty()) return textResult("Missing or empty 'vertices' array.", true); + QVector points; + for (const QJsonValue& v : verts) + { + const QJsonObject pt = v.toObject(); + points.append(QPointF(pt.value("x").toDouble(), pt.value("y").toDouble())); + } + MShape::ptr shape = layer->getShape(); + if (shape.isNull()) return textResult("Layer has no shape.", true); + if (points.size() != shape->nVertices()) + return textResult(QString("Expected %1 vertices, got %2.").arg(shape->nVertices()).arg(points.size()), true); + shape->setVertices(points); + _mainWindow->updateCanvases(); + return textResult(QString("Set %1 vertices on layer %2.").arg(points.size()).arg(id)); + } + // ---- Generic property set ---- if (name == "set_property") { @@ -536,6 +559,13 @@ QJsonArray McpServer::toolDefinitions() const }, QJsonArray{"id", "value"})); + tools.append(tool("set_vertices", + "Set the output vertices of a layer's shape.", + QJsonObject{ + {"id", prop("integer", "Layer id.")}, + {"vertices", QJsonObject{{"type", "array"}, {"description", "Array of {x, y} points. Count must match shape (3 for triangle, 4 for quad)."}}} + }, + QJsonArray{"id", "vertices"})); tools.append(tool("set_property", "Set an arbitrary property on a source or layer (e.g. name, opacity, color, uri).", QJsonObject{ From 501ca78044c545ee4dd81756683f6c894f75ac41 Mon Sep 17 00:00:00 2001 From: Alexandre Quessy Date: Tue, 26 May 2026 01:15:34 -0400 Subject: [PATCH 6/6] Guard mcpListeningPort write in writeSettings with HAVE_MCP The member variable is only declared under #ifdef HAVE_MCP, so writing it in writeSettings also needs the guard to fix the build when qthttpserver is unavailable. --- src/gui/MainWindow.cpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 5e632b942..e4ba53eb4 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -2568,7 +2568,9 @@ void MainWindow::writeSettings() settings.setValue("displayControls", displayControlsAction->isChecked()); settings.setValue("displayAllControls", displaySourceControlsAction->isChecked()); settings.setValue("oscListeningPort", oscListeningPort); +#ifdef HAVE_MCP settings.setValue("mcpListeningPort", mcpListeningPort); +#endif settings.setValue("displayUndoStack", displayUndoHistoryAction->isChecked()); settings.setValue("zoomToolBar", displayZoomToolAction->isChecked()); settings.setValue("showMenuBar", showMenuBarAction->isChecked());