diff --git a/Tactility/Private/Tactility/service/webserver/WebServerService.h b/Tactility/Private/Tactility/service/webserver/WebServerService.h index 6ca08467..4962c8de 100644 --- a/Tactility/Private/Tactility/service/webserver/WebServerService.h +++ b/Tactility/Private/Tactility/service/webserver/WebServerService.h @@ -83,8 +83,8 @@ private: static error_t handleApiScreenshot(struct HttpServerRequest* request, void* user_ctx); static error_t handleApiSimTouch(struct HttpServerRequest* request, void* user_ctx); static error_t handleSimViewer(struct HttpServerRequest* request, void* user_ctx); -#ifdef ESP_PLATFORM static error_t handleApiMcp(struct HttpServerRequest* request, void* user_ctx); +#ifdef ESP_PLATFORM static error_t handleApiScreenRaw(struct HttpServerRequest* request, void* user_ctx); #endif diff --git a/Tactility/Source/Tactility.cpp b/Tactility/Source/Tactility.cpp index e4e38c48..9770118c 100644 --- a/Tactility/Source/Tactility.cpp +++ b/Tactility/Source/Tactility.cpp @@ -194,6 +194,8 @@ namespace app { #if CONFIG_TT_TDECK_WORKAROUND == 1 namespace keyboardsettings { extern const ::AppManifest manifest; } // T-Deck only for now #endif +#else + namespace mcpsettings { extern const ::AppManifest manifest; } #endif namespace trackballsettings { extern const ::AppManifest manifest; } // T-Deck only for now @@ -252,12 +254,14 @@ static void registerInternalApps() { app_manager_add(&app::development::manifest); app_manager_add(&app::webserversettings::manifest); #ifdef ESP_PLATFORM + app_manager_add(&app::mcpsettings::manifest); app_manager_add(&app::apwebserver::manifest); app_manager_add(&app::crashdiagnostics::manifest); - app_manager_add(&app::mcpsettings::manifest); #if defined(CONFIG_TT_TDECK_WORKAROUND) app_manager_add(&app::keyboardsettings::manifest); #endif +#else + app_manager_add(&app::mcpsettings::manifest); #endif if (device_exists_of_type(&TRACKBALL_TYPE)) { diff --git a/Tactility/Source/app/mcpsettings/McpSettingsSimulatorApp.cpp b/Tactility/Source/app/mcpsettings/McpSettingsSimulatorApp.cpp new file mode 100644 index 00000000..539389a7 --- /dev/null +++ b/Tactility/Source/app/mcpsettings/McpSettingsSimulatorApp.cpp @@ -0,0 +1,66 @@ +#ifndef ESP_PLATFORM + +#include +#include +#include +#include +#include + +namespace tt::app::mcpsettings { +struct Context { AppInstanceId instanceId; }; + +void createMcpInfo(lv_obj_t* parent, void* userData) { + auto* context = static_cast(userData); + lv_obj_t* title = lv_label_create(parent); + lv_label_set_text(title, "MCP Settings"); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 18); + + lv_obj_t* info = lv_label_create(parent); + lv_label_set_text(info, + "MCP is available while the simulator web server is running.\n\n" + "Endpoint: POST /api/mcp\n" + "JSON-RPC methods: tools/list, tools/call\n\n" + "Simulator tools: get_capabilities, list_apps, run_app\n\n" + "Open the simulator dashboard/API at http://127.0.0.1/api/sysinfo."); + lv_label_set_long_mode(info, LV_LABEL_LONG_WRAP); + lv_obj_set_width(info, LV_PCT(90)); + lv_obj_align(info, LV_ALIGN_CENTER, 0, 8); + + (void)context; +} + +int32_t appMain(int, char**) { + Context context{app_scheduler_current_app_id()}; + TaskEventGroup group{}; + task_event_group_construct(&group); + AppEventSubscription subscription{}; + if (app_event_subscribe(&subscription, &group) != ERROR_NONE) { + task_event_group_destruct(&group); + return -1; + } + auto window = window_manager_create(context.instanceId, createMcpInfo, &context); + bool close = false; + while (!close) { + task_event_group_wait_any(&group, nullptr, portMAX_DELAY); + AppEvent event{}; + while (app_event_poll(&subscription, &event) == ERROR_NONE) { + if (event.type == APP_EVENT_CLOSE) { close = true; break; } + } + } + window_manager_remove(window); + app_event_unsubscribe(&subscription); + task_event_group_destruct(&group); + return 0; +} + +extern const AppManifest manifest = { + .id = "McpSettings", + .name = "MCP Settings", + .category = APP_CATEGORY_SETTINGS, + .location = {APP_LOCATION_MEMORY, reinterpret_cast(appMain)}, + .flags = 0, + .stack = {.depth = 8192, .desired_memory_capability = 0} +}; +} + +#endif diff --git a/Tactility/Source/service/webserver/McpSimulatorHandler.cpp b/Tactility/Source/service/webserver/McpSimulatorHandler.cpp new file mode 100644 index 00000000..78c6fb5b --- /dev/null +++ b/Tactility/Source/service/webserver/McpSimulatorHandler.cpp @@ -0,0 +1,179 @@ +#ifndef ESP_PLATFORM + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace tt::service::webserver { +namespace { +constexpr size_t JSON_BODY_LIMIT = 64 * 1024; + +cJSON* makeError(const cJSON* id, int code, const char* message) { + cJSON* response = cJSON_CreateObject(); + cJSON_AddStringToObject(response, "jsonrpc", "2.0"); + cJSON* error = cJSON_AddObjectToObject(response, "error"); + cJSON_AddNumberToObject(error, "code", code); + cJSON_AddStringToObject(error, "message", message); + if (id != nullptr) cJSON_AddItemToObject(response, "id", cJSON_Duplicate(id, true)); + else cJSON_AddNullToObject(response, "id"); + return response; +} + +cJSON* makeTextResult(const cJSON* id, const char* text, bool isError = false) { + cJSON* response = cJSON_CreateObject(); + cJSON_AddStringToObject(response, "jsonrpc", "2.0"); + cJSON* result = cJSON_AddObjectToObject(response, "result"); + cJSON* content = cJSON_AddArrayToObject(result, "content"); + cJSON* item = cJSON_CreateObject(); + cJSON_AddStringToObject(item, "type", "text"); + cJSON_AddStringToObject(item, "text", text); + cJSON_AddItemToArray(content, item); + if (isError) cJSON_AddBoolToObject(result, "isError", true); + if (id != nullptr) cJSON_AddItemToObject(response, "id", cJSON_Duplicate(id, true)); + else cJSON_AddNullToObject(response, "id"); + return response; +} + +cJSON* makeToolList(const cJSON* id) { + cJSON* response = cJSON_CreateObject(); + cJSON_AddStringToObject(response, "jsonrpc", "2.0"); + cJSON* result = cJSON_AddObjectToObject(response, "result"); + cJSON* tools = cJSON_AddArrayToObject(result, "tools"); + + cJSON* capabilities = cJSON_CreateObject(); + cJSON_AddStringToObject(capabilities, "name", "get_capabilities"); + cJSON_AddStringToObject(capabilities, "description", "Get the simulator platform and display capability information."); + cJSON_AddItemToObject(capabilities, "inputSchema", cJSON_Parse("{\"type\":\"object\",\"properties\":{}}")); + cJSON_AddItemToArray(tools, capabilities); + + cJSON* listApps = cJSON_CreateObject(); + cJSON_AddStringToObject(listApps, "name", "list_apps"); + cJSON_AddStringToObject(listApps, "description", "List apps available in the simulator."); + cJSON_AddItemToObject(listApps, "inputSchema", cJSON_Parse("{\"type\":\"object\",\"properties\":{}}")); + cJSON_AddItemToArray(tools, listApps); + + cJSON* runApp = cJSON_CreateObject(); + cJSON_AddStringToObject(runApp, "name", "run_app"); + cJSON_AddStringToObject(runApp, "description", "Launch an installed simulator app by its app ID."); + cJSON_AddItemToObject(runApp, "inputSchema", cJSON_Parse("{\"type\":\"object\",\"properties\":{\"app_id\":{\"type\":\"string\"}},\"required\":[\"app_id\"]}")); + cJSON_AddItemToArray(tools, runApp); + + if (id != nullptr) cJSON_AddItemToObject(response, "id", cJSON_Duplicate(id, true)); + else cJSON_AddNullToObject(response, "id"); + return response; +} + +std::string listAppsJson() { + std::vector manifests; + app_manager_for_each_manifest([](const AppManifest* manifest, void* context) { + static_cast*>(context)->push_back(manifest); + }, &manifests); + + cJSON* apps = cJSON_CreateArray(); + for (const AppManifest* manifest : manifests) { + cJSON* app = cJSON_CreateObject(); + cJSON_AddStringToObject(app, "id", manifest->id); + cJSON_AddStringToObject(app, "name", manifest->name); + cJSON_AddItemToArray(apps, app); + } + char* text = cJSON_PrintUnformatted(apps); + std::string output = text == nullptr ? "[]" : text; + cJSON_free(text); + cJSON_Delete(apps); + return output; +} + +cJSON* handleCall(const cJSON* id, const cJSON* params) { + const cJSON* name = cJSON_GetObjectItemCaseSensitive(params, "name"); + const cJSON* args = cJSON_GetObjectItemCaseSensitive(params, "arguments"); + if (!cJSON_IsString(name) || (args != nullptr && !cJSON_IsObject(args))) { + return makeError(id, -32602, "Invalid params"); + } + + if (strcmp(name->valuestring, "get_capabilities") == 0) { + return makeTextResult(id, "{\"platform\":\"tactility-simulator\",\"mcp\":true}"); + } + if (strcmp(name->valuestring, "list_apps") == 0) { + const std::string apps = listAppsJson(); + return makeTextResult(id, apps.c_str()); + } + if (strcmp(name->valuestring, "run_app") == 0) { + const cJSON* appId = cJSON_GetObjectItemCaseSensitive(args, "app_id"); + if (!cJSON_IsString(appId) || appId->valuestring == nullptr || appId->valuestring[0] == '\0') { + return makeError(id, -32602, "app_id is required"); + } + AppManifest manifest{}; + if (app_manager_find_manifest(appId->valuestring, &manifest) != ERROR_NONE) { + return makeTextResult(id, "app not found", true); + } + AppInstanceId instanceId = 0; + const error_t result = app_start(appId->valuestring, 0, nullptr, &instanceId); + if (result != ERROR_NONE) return makeTextResult(id, "app launch failed", true); + return makeTextResult(id, "ok"); + } + return makeError(id, -32602, "Unknown simulator MCP tool"); +} + +cJSON* handleRpc(const char* body) { + cJSON* request = cJSON_Parse(body); + if (request == nullptr) return makeError(nullptr, -32700, "Parse error"); + const cJSON* id = cJSON_GetObjectItemCaseSensitive(request, "id"); + const cJSON* version = cJSON_GetObjectItemCaseSensitive(request, "jsonrpc"); + const cJSON* method = cJSON_GetObjectItemCaseSensitive(request, "method"); + const cJSON* params = cJSON_GetObjectItemCaseSensitive(request, "params"); + + cJSON* response = nullptr; + if (!cJSON_IsObject(request) || !cJSON_IsString(version) || strcmp(version->valuestring, "2.0") != 0 || !cJSON_IsString(method)) { + response = makeError(id, -32600, "Invalid Request"); + } else if (strcmp(method->valuestring, "tools/list") == 0) { + response = makeToolList(id); + } else if (strcmp(method->valuestring, "tools/call") == 0) { + response = handleCall(id, params); + } else { + response = makeError(id, -32601, "Method not found"); + } + cJSON_Delete(request); + return response; +} +} + +error_t WebServerService::handleApiMcp(HttpServerRequest* request, void*) { + const uint64_t length = http_server_request_get_content_length(request); + if (length == 0 || length > JSON_BODY_LIMIT) { + http_server_request_send_error(request, 400, "Invalid Content-Length"); + return ERROR_INVALID_ARGUMENT; + } + std::string body(static_cast(length), '\0'); + size_t received = 0; + while (received < body.size()) { + const int count = http_server_request_receive(request, body.data() + received, body.size() - received); + if (count <= 0) { + http_server_request_send_error(request, 400, "Incomplete request body"); + return ERROR_UNDEFINED; + } + received += static_cast(count); + } + + cJSON* response = handleRpc(body.c_str()); + char* text = cJSON_PrintUnformatted(response); + cJSON_Delete(response); + if (text == nullptr) { + http_server_request_send_error(request, 500, "Serialization failed"); + return ERROR_RESOURCE; + } + http_server_request_set_content_type(request, "application/json"); + http_server_request_send_string(request, text); + cJSON_free(text); + return ERROR_NONE; +} +} + +#endif // ESP_PLATFORM diff --git a/Tactility/Source/service/webserver/WebServerService.cpp b/Tactility/Source/service/webserver/WebServerService.cpp index 6b5e332e..06b31c5a 100644 --- a/Tactility/Source/service/webserver/WebServerService.cpp +++ b/Tactility/Source/service/webserver/WebServerService.cpp @@ -507,14 +507,14 @@ bool WebServerService::startServer() { .callback = handleAdminPost, .user_ctx = ctx }, -#ifdef ESP_PLATFORM - // MCP is LAN-local and is enabled whenever the web server is enabled. + // MCP is available on physical devices and the POSIX simulator. { .uri = "/api/mcp", .method = HTTP_METHOD_POST, .callback = handleApiMcp, .user_ctx = ctx }, +#ifdef ESP_PLATFORM { .uri = "/api/screen/raw", .method = HTTP_METHOD_POST,