diff --git a/.claude/skills/mapmap-mcp.md b/.claude/skills/mapmap-mcp.md new file mode 100644 index 00000000..4f6b4789 --- /dev/null +++ b/.claude/skills/mapmap-mcp.md @@ -0,0 +1,103 @@ +--- +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 | + +### 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 | +|------|------|-------------| +| `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. 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 + +Success returns `{"isError": false, "content": [...]}` with optional `structuredContent`. +Errors return `{"isError": true, "content": [{"type":"text","text":"error message"}]}`. diff --git a/src/app/main.cpp b/src/app/main.cpp index c5efc82e..bcd415ad 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -105,6 +105,13 @@ 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", "Use language .", "lang", ""); @@ -213,6 +220,12 @@ 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); if (optionOk) diff --git a/src/control/McpServer.cpp b/src/control/McpServer.cpp new file mode 100644 index 00000000..1accace4 --- /dev/null +++ b/src/control/McpServer.cpp @@ -0,0 +1,594 @@ +/* + * 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 "Shape.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 == "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)); + 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)); + } + + 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") + { + 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("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"})); + + 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_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{ + {"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 00000000..ee518ecd --- /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 4f2455ed..7a838313 100644 --- a/src/control/control.pri +++ b/src/control/control.pri @@ -5,6 +5,11 @@ HEADERS += $$PWD/ConcurrentQueue.h \ SOURCES += $$PWD/OscInterface.cpp +contains(DEFINES, HAVE_MCP) { + HEADERS += $$PWD/McpServer.h + SOURCES += $$PWD/McpServer.cpp +} + # OSC support: INCLUDEPATH += $$PWD/qosc INCLUDEPATH += $$PWD/qosc/contrib/packosc diff --git a/src/core/MM.h b/src/core/MM.h index a9fec16a..d0a1d5cf 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 b47d3cef..e4ba53eb 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -89,6 +89,11 @@ MainWindow::MainWindow() // Start osc. startOscReceiver(); +#ifdef HAVE_MCP + // Start MCP server. + startMcpServer(); +#endif + // Defaults. setWindowIcon(QIcon(":/mapmap-logo")); setCurrentFile(""); @@ -2527,6 +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(); @@ -2560,6 +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()); @@ -3736,6 +3747,59 @@ bool MainWindow::setOscPort(QString portNumber) return true; } +#ifdef HAVE_MCP +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; + } +} +#endif // HAVE_MCP + 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 556293dd..f590589f 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -37,6 +37,9 @@ #include "MapperGLCanvas.h" #include "MapperGLCanvasToolbar.h" #include "OscInterface.h" +#ifdef HAVE_MCP +#include "McpServer.h" +#endif #include "OutputGLWindow.h" #include "ConsoleWindow.h" @@ -137,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(); @@ -171,6 +171,11 @@ private slots: public slots: + // Layer creation. + void addMesh(); + void addTriangle(); + void addEllipse(); + // CRUD. /// Clears all mappings and sources. @@ -302,6 +307,11 @@ public slots: // OSC. void startOscReceiver(); +#ifdef HAVE_MCP + // MCP server. + void startMcpServer(); +#endif + // Actions-related. bool okToContinue(); @@ -518,6 +528,12 @@ public slots: int oscListeningPort; QTimer *osc_timer; +#ifdef HAVE_MCP + // MCP server. + QScopedPointer mcp_server; + int mcpListeningPort; +#endif + // View. // The view counterpart of Mappings. @@ -624,6 +640,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 155c0d70..ce330dfc 100644 --- a/src/gui/PreferenceDialog.cpp +++ b/src/gui/PreferenceDialog.cpp @@ -122,6 +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()); @@ -156,6 +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()); } @@ -373,6 +382,28 @@ void PreferenceDialog::createControlsPage() _controlsPage->addTab(_oscWidget, tr("OSC Setup")); +#ifdef HAVE_MCP + // 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")); +#endif + refreshCurrentIP(); } diff --git a/src/gui/PreferenceDialog.h b/src/gui/PreferenceDialog.h index 1c40b167..99641fe2 100644 --- a/src/gui/PreferenceDialog.h +++ b/src/gui/PreferenceDialog.h @@ -109,6 +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 96369375..248a3cee 100644 --- a/src/src.pri +++ b/src/src.pri @@ -6,6 +6,13 @@ QT += network QT += multimedia QT += multimediawidgets QT += widgets +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 \