From ac9d6dc0d39ffa9cb3cc1be659c24bb0323a2628 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Wed, 24 Jun 2026 23:48:21 -0400 Subject: [PATCH] feat: add Tactility MCP Screen foundation --- Apps/McpScreen/CMakeLists.txt | 16 + Apps/McpScreen/IMPLEMENTATION_PLAN.md | 42 +- Apps/McpScreen/README.md | 61 +++ Apps/McpScreen/main/CMakeLists.txt | 8 + Apps/McpScreen/main/Source/McpScreen.c | 241 ++++++++++++ Apps/McpScreen/main/Source/McpScreen.h | 49 +++ Apps/McpScreen/main/Source/McpServer.c | 516 +++++++++++++++++++++++++ Apps/McpScreen/manifest.properties | 10 + Apps/McpScreen/tools/smoke_test.py | 115 ++++++ 9 files changed, 1057 insertions(+), 1 deletion(-) create mode 100644 Apps/McpScreen/CMakeLists.txt create mode 100644 Apps/McpScreen/README.md create mode 100644 Apps/McpScreen/main/CMakeLists.txt create mode 100644 Apps/McpScreen/main/Source/McpScreen.c create mode 100644 Apps/McpScreen/main/Source/McpScreen.h create mode 100644 Apps/McpScreen/main/Source/McpServer.c create mode 100644 Apps/McpScreen/manifest.properties create mode 100644 Apps/McpScreen/tools/smoke_test.py diff --git a/Apps/McpScreen/CMakeLists.txt b/Apps/McpScreen/CMakeLists.txt new file mode 100644 index 0000000..b465c36 --- /dev/null +++ b/Apps/McpScreen/CMakeLists.txt @@ -0,0 +1,16 @@ +cmake_minimum_required(VERSION 3.20) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +if (DEFINED ENV{TACTILITY_SDK_PATH}) + set(TACTILITY_SDK_PATH $ENV{TACTILITY_SDK_PATH}) +else() + set(TACTILITY_SDK_PATH "../../release/TactilitySDK") + message(WARNING "TACTILITY_SDK_PATH is not set, defaulting to ${TACTILITY_SDK_PATH}") +endif() + +include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake") +set(EXTRA_COMPONENT_DIRS ${TACTILITY_SDK_PATH}) + +project(McpScreen) +tactility_project(McpScreen) diff --git a/Apps/McpScreen/IMPLEMENTATION_PLAN.md b/Apps/McpScreen/IMPLEMENTATION_PLAN.md index 3da76f7..a037305 100644 --- a/Apps/McpScreen/IMPLEMENTATION_PLAN.md +++ b/Apps/McpScreen/IMPLEMENTATION_PLAN.md @@ -27,6 +27,47 @@ The original reference documents and implementations are: - Initial Tactility SDK: `0.7.0-dev` - Language: C/C++ with ESP-IDF, FreeRTOS, Tactility SDK, and LVGL +## Current implementation status + +Phase 1 is implemented, builds successfully for ESP32-S3, and has been tested +on a 320x240 Tactility device at runtime. + +Completed: + +- Tactility app lifecycle and app-owned drawing area +- HTTP JSON-RPC endpoint on port 80 +- `tools/list` +- `get_capabilities` +- `clear_screen` +- `draw_text` +- request-size limits and JSON-RPC error responses +- live `tools/list` and `get_capabilities` calls +- live `clear_screen` and `draw_text` calls +- live malformed-JSON error handling +- crash-safe HTTP worker shutdown during app destruction +- three forced stop/start cycles with the management and MCP endpoints still + responsive afterward + +Blocked by the current external-app ABI: + +- UDP discovery requires `lwip_recvfrom` and `lwip_sendto`, which the running + Tactility 0.7.0-dev firmware does not export to ELF apps. The existing bridge + falls back to HTTP subnet scanning, so MCP connectivity remains available. + +Pending verification: + +- verify service behavior while the app is genuinely hidden behind another + running application. A remote HelloWorld launch did not trigger `onHide`, so + this needs a manual launcher/device interaction test. + +### Shutdown implementation note + +Tactility unloads external-app ELF memory immediately after `onDestroy` +returns. An app task blocked in `accept()` therefore cannot be allowed to +survive destruction. The HTTP listener is non-blocking, client reads have +short timeouts, and `onDestroy` closes sockets, waits for the worker to signal +exit, then deletes the suspended worker task before returning. + ## Design principles 1. Preserve the existing MCP tool names and JSON-RPC contract wherever the @@ -382,4 +423,3 @@ Implement Phase 1 as a minimal vertical slice: 6. Add `get_capabilities`, `clear_screen`, and `draw_text`. 7. Build. 8. Run a host smoke test through the existing bridge. - diff --git a/Apps/McpScreen/README.md b/Apps/McpScreen/README.md new file mode 100644 index 0000000..746ff33 --- /dev/null +++ b/Apps/McpScreen/README.md @@ -0,0 +1,61 @@ +# MCP Screen + +Native Tactility port of the MicroPython MCP Screen firmware. + +The current Phase 1 slice provides: + +- JSON-RPC 2.0 over `POST /api/mcp` on port 80 +- `tools/list` +- `get_capabilities` +- `clear_screen` +- `draw_text` + +See `IMPLEMENTATION_PLAN.md` for the full roadmap. + +UDP discovery is temporarily unavailable to an external app because the +Tactility 0.7.0-dev firmware does not export `lwip_recvfrom` and +`lwip_sendto`. The existing MicroPython bridge remains compatible through its +HTTP subnet-scan fallback. + +The HTTP worker uses a non-blocking listener and is explicitly stopped and +deleted during `onDestroy`. This is required because Tactility unloads the +external ELF after the app closes. + +## Build + +```bash +. /Users/adolforeyna/esp/esp-idf/export.sh +export TACTILITY_SDK_PATH=/Users/adolforeyna/.gemini/antigravity/scratch/tactility/release/TactilitySDK +python3 tactility.py Apps/McpScreen build esp32s3 --local-sdk +``` + +## Direct smoke test + +Replace the IP address with the device address: + +```bash +curl -s http://DEVICE_IP/api/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' +``` + +```bash +curl -s http://DEVICE_IP/api/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"draw_text","arguments":{"text":"Hello from MCP","x":20,"y":30,"size":2}}}' +``` + +The existing MicroPython host bridge can also be used unchanged: + +```bash +python3 /Users/adolforeyna/Projects/MicroPython/test1/Screen/mcp_bridge.py +``` + +Or run the included Phase 1 smoke test: + +```bash +python3 Apps/McpScreen/tools/smoke_test.py --ip DEVICE_IP --draw +``` + +The `--ip` argument is required until UDP discovery is available to external +apps. diff --git a/Apps/McpScreen/main/CMakeLists.txt b/Apps/McpScreen/main/CMakeLists.txt new file mode 100644 index 0000000..7d660cd --- /dev/null +++ b/Apps/McpScreen/main/CMakeLists.txt @@ -0,0 +1,8 @@ +file(GLOB_RECURSE SOURCE_FILES Source/*.c) +set(CJSON_SOURCE "$ENV{IDF_PATH}/components/json/cJSON/cJSON.c") + +idf_component_register( + SRCS ${SOURCE_FILES} ${CJSON_SOURCE} + INCLUDE_DIRS Source "$ENV{IDF_PATH}/components/json/cJSON" + REQUIRES TactilitySDK lwip +) diff --git a/Apps/McpScreen/main/Source/McpScreen.c b/Apps/McpScreen/main/Source/McpScreen.c new file mode 100644 index 0000000..741cae4 --- /dev/null +++ b/Apps/McpScreen/main/Source/McpScreen.c @@ -0,0 +1,241 @@ +#include "McpScreen.h" + +#include +#include +#include + +#include +#include +#include +#include + +static const char* TAG = "McpScreen"; + +static void set_label_text_locked(lv_obj_t* label, const char* text) { + if (label != NULL && lv_obj_is_valid(label)) { + lv_label_set_text(label, text); + } +} + +void mcp_ui_set_server_status(McpScreenState* state, const char* status) { + if (state == NULL || !state->visible) { + return; + } + + if (tt_lvgl_lock(pdMS_TO_TICKS(1000))) { + if (state->visible) { + set_label_text_locked(state->server_label, status); + } + tt_lvgl_unlock(); + } +} + +void mcp_ui_set_last_tool(McpScreenState* state, const char* tool) { + if (state == NULL || !state->visible) { + return; + } + + if (tt_lvgl_lock(pdMS_TO_TICKS(1000))) { + if (state->visible && state->tool_label != NULL && lv_obj_is_valid(state->tool_label)) { + lv_label_set_text_fmt(state->tool_label, "Last: %s", tool); + } + tt_lvgl_unlock(); + } +} + +bool mcp_ui_clear(McpScreenState* state, int color) { + if (state == NULL || !state->visible) { + return false; + } + + bool success = false; + if (tt_lvgl_lock(pdMS_TO_TICKS(1500))) { + if (state->visible && state->draw_area != NULL && lv_obj_is_valid(state->draw_area)) { + lv_obj_clean(state->draw_area); + lv_obj_set_style_bg_color( + state->draw_area, + color == 1 ? lv_color_black() : lv_color_white(), + LV_PART_MAIN + ); + lv_obj_set_style_bg_opa(state->draw_area, LV_OPA_COVER, LV_PART_MAIN); + state->draw_color = color; + state->override_active = true; + success = true; + } + tt_lvgl_unlock(); + } + return success; +} + +bool mcp_ui_draw_text( + McpScreenState* state, + const char* text, + int x, + int y, + int size +) { + if (state == NULL || text == NULL || !state->visible) { + return false; + } + + bool success = false; + if (tt_lvgl_lock(pdMS_TO_TICKS(1500))) { + if (state->visible && state->draw_area != NULL && lv_obj_is_valid(state->draw_area)) { + int max_x = state->draw_width > 0 ? state->draw_width - 1 : 0; + int max_y = state->draw_height > 0 ? state->draw_height - 1 : 0; + if (x < 0) x = 0; + if (y < 0) y = 0; + if (x > max_x) x = max_x; + if (y > max_y) y = max_y; + + lv_obj_t* label = lv_label_create(state->draw_area); + lv_label_set_text(label, text); + lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); + lv_obj_set_width(label, LV_MAX(1, state->draw_width - x)); + lv_obj_set_pos(label, x, y); + + lv_obj_set_style_text_color( + label, + state->draw_color == 0 ? lv_color_black() : lv_color_white(), + LV_PART_MAIN + ); + +#if LV_FONT_MONTSERRAT_24 + if (size >= 2) { + lv_obj_set_style_text_font(label, &lv_font_montserrat_24, LV_PART_MAIN); + } +#else + (void)size; +#endif + + state->override_active = true; + success = true; + } + tt_lvgl_unlock(); + } + return success; +} + +static void* create_data(void) { + McpScreenState* state = calloc(1, sizeof(McpScreenState)); + if (state != NULL) { + state->http_socket = -1; + state->client_socket = -1; + state->discovery_socket = -1; + } + return state; +} + +static void destroy_data(void* data) { + free(data); +} + +static void on_create(AppHandle app, void* data) { + McpScreenState* state = data; + state->app = app; + if (!mcp_services_start(state)) { + ESP_LOGE(TAG, "One or more MCP services failed to start"); + } +} + +static void on_destroy(AppHandle app, void* data) { + (void)app; + mcp_services_stop((McpScreenState*)data); +} + +static void on_show(AppHandle app, void* data, lv_obj_t* parent) { + McpScreenState* state = data; + state->visible = true; + + lv_obj_remove_flag(parent, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(parent, 0, LV_PART_MAIN); + lv_obj_set_style_pad_row(parent, 0, LV_PART_MAIN); + + lv_obj_t* toolbar = tt_lvgl_toolbar_create_for_app(parent, app); + lv_obj_align(toolbar, LV_ALIGN_TOP_MID, 0, 0); + + state->server_label = lv_label_create(toolbar); + lv_label_set_text(state->server_label, state->http_ready ? "MCP :80 ready" : "MCP starting"); + lv_obj_set_style_text_color( + state->server_label, + state->http_ready ? lv_palette_main(LV_PALETTE_GREEN) : lv_palette_main(LV_PALETTE_ORANGE), + LV_PART_MAIN + ); + + state->tool_label = lv_label_create(toolbar); + lv_label_set_text(state->tool_label, "Last: none"); + + state->draw_area = lv_obj_create(parent); + lv_obj_set_width(state->draw_area, LV_PCT(100)); + lv_obj_set_flex_grow(state->draw_area, 1); + lv_obj_set_style_radius(state->draw_area, 0, LV_PART_MAIN); + lv_obj_set_style_border_width(state->draw_area, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(state->draw_area, 0, LV_PART_MAIN); + lv_obj_remove_flag(state->draw_area, LV_OBJ_FLAG_SCROLLABLE); + + lv_display_t* display = lv_obj_get_display(parent); + state->display_width = lv_display_get_horizontal_resolution(display); + state->display_height = lv_display_get_vertical_resolution(display); + + lv_obj_update_layout(parent); + state->draw_width = lv_obj_get_content_width(state->draw_area); + state->draw_height = lv_obj_get_content_height(state->draw_area); + + if (!state->override_active) { + lv_obj_set_style_bg_color(state->draw_area, lv_color_hex(0x101820), LV_PART_MAIN); + lv_obj_set_style_bg_opa(state->draw_area, LV_OPA_COVER, LV_PART_MAIN); + state->draw_color = 1; + + lv_obj_t* title = lv_label_create(state->draw_area); + lv_label_set_text(title, "MCP Screen"); + lv_obj_set_style_text_color(title, lv_color_white(), LV_PART_MAIN); +#if LV_FONT_MONTSERRAT_24 + lv_obj_set_style_text_font(title, &lv_font_montserrat_24, LV_PART_MAIN); +#endif + lv_obj_align(title, LV_ALIGN_CENTER, 0, -55); + + lv_obj_t* dimensions = lv_label_create(state->draw_area); + lv_label_set_text_fmt( + dimensions, + "%ux%u display\nHTTP :80\nDiscovery: bridge subnet scan", + state->display_width, + state->display_height + ); + lv_obj_set_style_text_align(dimensions, LV_TEXT_ALIGN_CENTER, LV_PART_MAIN); + lv_obj_set_style_text_color(dimensions, lv_palette_lighten(LV_PALETTE_BLUE, 3), LV_PART_MAIN); + lv_obj_align(dimensions, LV_ALIGN_CENTER, 0, 0); + + lv_obj_t* wifi = lv_label_create(state->draw_area); + lv_label_set_text_fmt( + wifi, + "Wi-Fi: %s", + tt_wifi_radio_state_to_string(tt_wifi_get_radio_state()) + ); + lv_obj_set_style_text_color(wifi, lv_color_white(), LV_PART_MAIN); + lv_obj_align(wifi, LV_ALIGN_CENTER, 0, 48); + } +} + +static void on_hide(AppHandle app, void* data) { + (void)app; + McpScreenState* state = data; + state->visible = false; + state->draw_area = NULL; + state->server_label = NULL; + state->tool_label = NULL; +} + +int main(int argc, char* argv[]) { + (void)argc; + (void)argv; + tt_app_register((AppRegistration) { + .createData = create_data, + .destroyData = destroy_data, + .onCreate = on_create, + .onDestroy = on_destroy, + .onShow = on_show, + .onHide = on_hide + }); + return 0; +} diff --git a/Apps/McpScreen/main/Source/McpScreen.h b/Apps/McpScreen/main/Source/McpScreen.h new file mode 100644 index 0000000..9ee5ff2 --- /dev/null +++ b/Apps/McpScreen/main/Source/McpScreen.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +typedef struct { + volatile bool running; + volatile bool http_ready; + volatile bool http_exited; + volatile bool discovery_ready; + volatile bool visible; + volatile bool override_active; + + int http_socket; + int client_socket; + int discovery_socket; + TaskHandle_t http_task; + TaskHandle_t discovery_task; + + AppHandle app; + lv_obj_t* draw_area; + lv_obj_t* server_label; + lv_obj_t* tool_label; + + uint16_t display_width; + uint16_t display_height; + uint16_t draw_width; + uint16_t draw_height; + int draw_color; +} McpScreenState; + +bool mcp_services_start(McpScreenState* state); +void mcp_services_stop(McpScreenState* state); + +void mcp_ui_set_server_status(McpScreenState* state, const char* status); +void mcp_ui_set_last_tool(McpScreenState* state, const char* tool); +bool mcp_ui_clear(McpScreenState* state, int color); +bool mcp_ui_draw_text( + McpScreenState* state, + const char* text, + int x, + int y, + int size +); diff --git a/Apps/McpScreen/main/Source/McpServer.c b/Apps/McpScreen/main/Source/McpServer.c new file mode 100644 index 0000000..352dc83 --- /dev/null +++ b/Apps/McpScreen/main/Source/McpServer.c @@ -0,0 +1,516 @@ +#include "McpScreen.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#define MCP_HTTP_PORT 80 +#define MCP_HEADER_LIMIT 2048 +#define MCP_BODY_LIMIT 8192 +#define MCP_RESPONSE_LIMIT 8192 + +static const char* TAG = "McpServer"; + +static const char* TOOLS_JSON = + "[" + "{" + "\"name\":\"get_capabilities\"," + "\"description\":\"Get the Tactility display capabilities.\"," + "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}" + "}," + "{" + "\"name\":\"clear_screen\"," + "\"description\":\"Clear the MCP drawing area to white (0) or black (1).\"," + "\"inputSchema\":{" + "\"type\":\"object\"," + "\"properties\":{\"color\":{\"type\":\"integer\",\"enum\":[0,1]}}," + "\"required\":[\"color\"]" + "}" + "}," + "{" + "\"name\":\"draw_text\"," + "\"description\":\"Draw text in the MCP drawing area at x,y coordinates.\"," + "\"inputSchema\":{" + "\"type\":\"object\"," + "\"properties\":{" + "\"text\":{\"type\":\"string\"}," + "\"x\":{\"type\":\"integer\"}," + "\"y\":{\"type\":\"integer\"}," + "\"size\":{\"type\":\"integer\",\"enum\":[1,2],\"default\":1}" + "}," + "\"required\":[\"text\",\"x\",\"y\"]" + "}" + "}" + "]"; + +static void close_socket(int* socket_fd) { + if (socket_fd != NULL && *socket_fd >= 0) { + close(*socket_fd); + *socket_fd = -1; + } +} + +static bool send_all(int socket_fd, const char* data, size_t length) { + size_t sent_total = 0; + while (sent_total < length) { + int sent = send(socket_fd, data + sent_total, length - sent_total, 0); + if (sent <= 0) { + return false; + } + sent_total += (size_t)sent; + } + return true; +} + +static cJSON* make_error(cJSON* id, int code, const char* message) { + cJSON* root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "jsonrpc", "2.0"); + + cJSON* error = cJSON_AddObjectToObject(root, "error"); + cJSON_AddNumberToObject(error, "code", code); + cJSON_AddStringToObject(error, "message", message); + + if (id != NULL) { + cJSON_AddItemToObject(root, "id", cJSON_Duplicate(id, true)); + } else { + cJSON_AddNullToObject(root, "id"); + } + return root; +} + +static cJSON* make_tool_result(cJSON* id, const char* text) { + cJSON* root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "jsonrpc", "2.0"); + + cJSON* result = cJSON_AddObjectToObject(root, "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 (id != NULL) { + cJSON_AddItemToObject(root, "id", cJSON_Duplicate(id, true)); + } else { + cJSON_AddNullToObject(root, "id"); + } + return root; +} + +static cJSON* handle_tool_call(McpScreenState* state, cJSON* id, cJSON* params) { + if (!cJSON_IsObject(params)) { + return make_error(id, -32602, "Invalid params"); + } + + cJSON* name_item = cJSON_GetObjectItemCaseSensitive(params, "name"); + cJSON* arguments = cJSON_GetObjectItemCaseSensitive(params, "arguments"); + if (!cJSON_IsString(name_item) || name_item->valuestring == NULL) { + return make_error(id, -32602, "Missing tool name"); + } + if (arguments != NULL && !cJSON_IsObject(arguments)) { + return make_error(id, -32602, "Tool arguments must be an object"); + } + + const char* name = name_item->valuestring; + mcp_ui_set_last_tool(state, name); + + if (strcmp(name, "get_capabilities") == 0) { + char capabilities[320]; + snprintf( + capabilities, + sizeof(capabilities), + "{\"color\":true,\"width\":%u,\"height\":%u," + "\"formats\":[\"rgb565_base64\",\"bmp_base64\"]," + "\"platform\":\"tactility\",\"appVersion\":\"0.1.0\"," + "\"uiVisible\":%s}", + state->display_width, + state->display_height, + state->visible ? "true" : "false" + ); + return make_tool_result(id, capabilities); + } + + if (strcmp(name, "clear_screen") == 0) { + cJSON* color_item = cJSON_GetObjectItemCaseSensitive(arguments, "color"); + if (!cJSON_IsNumber(color_item) || + (color_item->valueint != 0 && color_item->valueint != 1)) { + return make_error(id, -32602, "color must be 0 or 1"); + } + if (!mcp_ui_clear(state, color_item->valueint)) { + return make_error(id, -32010, "MCP Screen app is not visible"); + } + return make_tool_result(id, "Screen cleared."); + } + + if (strcmp(name, "draw_text") == 0) { + cJSON* text_item = cJSON_GetObjectItemCaseSensitive(arguments, "text"); + cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x"); + cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y"); + cJSON* size_item = cJSON_GetObjectItemCaseSensitive(arguments, "size"); + + if (!cJSON_IsString(text_item) || text_item->valuestring == NULL || + !cJSON_IsNumber(x_item) || !cJSON_IsNumber(y_item)) { + return make_error(id, -32602, "draw_text requires text, x, and y"); + } + if (strlen(text_item->valuestring) > 512) { + return make_error(id, -32602, "text exceeds 512 characters"); + } + + int size = cJSON_IsNumber(size_item) ? size_item->valueint : 1; + if (size != 1 && size != 2) { + return make_error(id, -32602, "size must be 1 or 2"); + } + + if (!mcp_ui_draw_text( + state, + text_item->valuestring, + x_item->valueint, + y_item->valueint, + size)) { + return make_error(id, -32010, "MCP Screen app is not visible"); + } + + char message[160]; + snprintf( + message, + sizeof(message), + "Successfully drew text at (%d, %d) with size %d.", + x_item->valueint, + y_item->valueint, + size + ); + return make_tool_result(id, message); + } + + return make_error(id, -32602, "Unknown tool"); +} + +static cJSON* handle_rpc(McpScreenState* state, const char* body) { + cJSON* request = cJSON_Parse(body); + if (request == NULL) { + return make_error(NULL, -32700, "Parse error"); + } + + cJSON* id = cJSON_GetObjectItemCaseSensitive(request, "id"); + cJSON* jsonrpc = cJSON_GetObjectItemCaseSensitive(request, "jsonrpc"); + cJSON* method = cJSON_GetObjectItemCaseSensitive(request, "method"); + cJSON* params = cJSON_GetObjectItemCaseSensitive(request, "params"); + + if (!cJSON_IsObject(request) || + !cJSON_IsString(jsonrpc) || + strcmp(jsonrpc->valuestring, "2.0") != 0 || + !cJSON_IsString(method)) { + cJSON* response = make_error(id, -32600, "Invalid Request"); + cJSON_Delete(request); + return response; + } + + cJSON* response = NULL; + if (strcmp(method->valuestring, "tools/list") == 0) { + cJSON* tools = cJSON_Parse(TOOLS_JSON); + if (tools == NULL) { + response = make_error(id, -32603, "Failed to construct tool list"); + } else { + response = cJSON_CreateObject(); + cJSON_AddStringToObject(response, "jsonrpc", "2.0"); + cJSON* result = cJSON_AddObjectToObject(response, "result"); + cJSON_AddItemToObject(result, "tools", tools); + if (id != NULL) { + cJSON_AddItemToObject(response, "id", cJSON_Duplicate(id, true)); + } else { + cJSON_AddNullToObject(response, "id"); + } + } + } else if (strcmp(method->valuestring, "tools/call") == 0) { + response = handle_tool_call(state, id, params); + } else { + response = make_error(id, -32601, "Method not found"); + } + + cJSON_Delete(request); + return response; +} + +static int find_header_end(const char* buffer, int length) { + for (int i = 0; i <= length - 4; ++i) { + if (buffer[i] == '\r' && buffer[i + 1] == '\n' && + buffer[i + 2] == '\r' && buffer[i + 3] == '\n') { + return i + 4; + } + } + return -1; +} + +static int parse_content_length(const char* headers) { + const char* cursor = headers; + while ((cursor = strstr(cursor, "\r\n")) != NULL) { + cursor += 2; + if (strncasecmp(cursor, "Content-Length:", 15) == 0) { + return atoi(cursor + 15); + } + } + return 0; +} + +static void send_http_response(int client, int status, const char* content_type, const char* body) { + const char* status_text = status == 200 ? "OK" : + status == 404 ? "Not Found" : + status == 405 ? "Method Not Allowed" : + status == 413 ? "Payload Too Large" : + "Bad Request"; + char header[320]; + int body_length = body == NULL ? 0 : (int)strlen(body); + int header_length = snprintf( + header, + sizeof(header), + "HTTP/1.1 %d %s\r\n" + "Content-Type: %s\r\n" + "Content-Length: %d\r\n" + "Connection: close\r\n" + "Access-Control-Allow-Origin: *\r\n" + "\r\n", + status, + status_text, + content_type, + body_length + ); + send_all(client, header, (size_t)header_length); + if (body_length > 0) { + send_all(client, body, (size_t)body_length); + } +} + +static void handle_http_client(McpScreenState* state, int client) { + struct timeval timeout = { .tv_sec = 0, .tv_usec = 500000 }; + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + + char* request = calloc(1, MCP_HEADER_LIMIT + MCP_BODY_LIMIT + 1); + if (request == NULL) { + send_http_response(client, 400, "text/plain", "Out of memory"); + return; + } + + int received = 0; + int header_end = -1; + while (received < MCP_HEADER_LIMIT) { + int count = recv(client, request + received, MCP_HEADER_LIMIT - received, 0); + if (count <= 0) { + free(request); + return; + } + received += count; + header_end = find_header_end(request, received); + if (header_end >= 0) { + break; + } + } + + if (header_end < 0) { + send_http_response(client, 400, "text/plain", "Invalid HTTP headers"); + free(request); + return; + } + + request[header_end - 4] = '\0'; + char method[12] = {0}; + char path[128] = {0}; + if (sscanf(request, "%11s %127s", method, path) != 2) { + send_http_response(client, 400, "text/plain", "Invalid request line"); + free(request); + return; + } + + if (strcmp(method, "POST") != 0) { + send_http_response(client, 405, "text/plain", "POST required"); + free(request); + return; + } + if (strcmp(path, "/api/mcp") != 0) { + send_http_response(client, 404, "text/plain", "Not found"); + free(request); + return; + } + + int content_length = parse_content_length(request); + if (content_length <= 0 || content_length > MCP_BODY_LIMIT) { + send_http_response( + client, + content_length > MCP_BODY_LIMIT ? 413 : 400, + "text/plain", + "Invalid Content-Length" + ); + free(request); + return; + } + + int body_received = received - header_end; + memmove(request, request + header_end, body_received); + while (body_received < content_length) { + int count = recv(client, request + body_received, content_length - body_received, 0); + if (count <= 0) { + send_http_response(client, 400, "text/plain", "Incomplete request body"); + free(request); + return; + } + body_received += count; + } + request[content_length] = '\0'; + + cJSON* response = handle_rpc(state, request); + char* response_text = cJSON_PrintUnformatted(response); + if (response_text != NULL && strlen(response_text) <= MCP_RESPONSE_LIMIT) { + send_http_response(client, 200, "application/json", response_text); + } else { + send_http_response(client, 400, "text/plain", "Response serialization failed"); + } + + cJSON_free(response_text); + cJSON_Delete(response); + free(request); +} + +static void http_task(void* argument) { + McpScreenState* state = argument; + state->http_exited = false; + int server = socket(AF_INET, SOCK_STREAM, IPPROTO_IP); + if (server < 0) { + ESP_LOGE(TAG, "Failed to create HTTP socket: errno=%d", errno); + state->http_ready = false; + mcp_ui_set_server_status(state, "MCP socket error"); + goto suspend_for_owner; + } + state->http_socket = server; + + int reuse = 1; + setsockopt(server, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + struct sockaddr_in address = { + .sin_family = AF_INET, + .sin_port = htons(MCP_HTTP_PORT), + .sin_addr.s_addr = htonl(INADDR_ANY) + }; + + if (bind(server, (struct sockaddr*)&address, sizeof(address)) != 0 || + listen(server, 3) != 0) { + ESP_LOGE(TAG, "Failed to bind/listen on TCP %d: errno=%d", MCP_HTTP_PORT, errno); + state->http_ready = false; + mcp_ui_set_server_status(state, "MCP bind error"); + close_socket(&state->http_socket); + goto suspend_for_owner; + } + + int flags = fcntl(server, F_GETFL, 0); + if (flags < 0 || fcntl(server, F_SETFL, flags | O_NONBLOCK) < 0) { + ESP_LOGE(TAG, "Failed to make HTTP listener non-blocking: errno=%d", errno); + state->http_ready = false; + mcp_ui_set_server_status(state, "MCP socket config error"); + close_socket(&state->http_socket); + goto suspend_for_owner; + } + + ESP_LOGI(TAG, "HTTP MCP server listening on port %d", MCP_HTTP_PORT); + state->http_ready = true; + mcp_ui_set_server_status(state, "MCP :80 ready"); + + while (state->running) { + struct sockaddr_storage source; + socklen_t source_length = sizeof(source); + int client = accept(server, (struct sockaddr*)&source, &source_length); + if (client < 0) { + if (!state->running) { + break; + } + vTaskDelay(pdMS_TO_TICKS(50)); + continue; + } + state->client_socket = client; + handle_http_client(state, client); + close_socket(&state->client_socket); + } + + close_socket(&state->client_socket); + close_socket(&state->http_socket); + state->http_ready = false; + +suspend_for_owner: + /* + * External-app code must not continue after Tactility unloads the ELF. + * Signal completion, then suspend in FreeRTOS code. onDestroy owns the + * final vTaskDelete() and will not return until the worker is gone. + */ + state->http_exited = true; + vTaskSuspend(NULL); + vTaskDelete(NULL); +} + +bool mcp_services_start(McpScreenState* state) { + if (state == NULL || state->running) { + return false; + } + + state->running = true; + state->http_exited = false; + BaseType_t http_result = xTaskCreate( + http_task, + "mcp_http", + 8192, + state, + 4, + &state->http_task + ); + /* + * UDP discovery requires lwip_recvfrom/lwip_sendto. Tactility 0.7.0-dev + * currently does not export those symbols to external ELF apps. The + * existing host bridge remains compatible through its HTTP subnet-scan + * fallback. Re-enable discovery when those symbols are exported. + */ + state->discovery_ready = false; + state->discovery_task = NULL; + + if (http_result != pdPASS) { + ESP_LOGE(TAG, "Failed to create MCP service tasks"); + mcp_services_stop(state); + return false; + } + return true; +} + +void mcp_services_stop(McpScreenState* state) { + if (state == NULL) { + return; + } + + state->running = false; + state->http_ready = false; + state->discovery_ready = false; + close_socket(&state->http_socket); + close_socket(&state->client_socket); + close_socket(&state->discovery_socket); + + for (int attempt = 0; attempt < 80; ++attempt) { + if (state->http_exited) { + break; + } + vTaskDelay(pdMS_TO_TICKS(10)); + } + + if (state->http_task != NULL) { + /* + * Normal path: worker is suspended after closing its sockets. + * Fallback path: force-delete after 800 ms so loader_dispatch can + * never be held until the task watchdog fires. + */ + vTaskDelete(state->http_task); + state->http_task = NULL; + } +} diff --git a/Apps/McpScreen/manifest.properties b/Apps/McpScreen/manifest.properties new file mode 100644 index 0000000..2d87ffe --- /dev/null +++ b/Apps/McpScreen/manifest.properties @@ -0,0 +1,10 @@ +[manifest] +version=0.1 +[target] +sdk=0.7.0-dev +platforms=esp32s3 +[app] +id=one.tactility.mcpscreen +versionName=0.1.0 +versionCode=1 +name=MCP Screen diff --git a/Apps/McpScreen/tools/smoke_test.py b/Apps/McpScreen/tools/smoke_test.py new file mode 100644 index 0000000..d6acc7d --- /dev/null +++ b/Apps/McpScreen/tools/smoke_test.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Host smoke test for the Tactility MCP Screen Phase 1 API.""" + +import argparse +import json +import socket +import urllib.request + + +def discover(timeout: float = 2.0) -> str | None: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.settimeout(timeout) + try: + sock.sendto(b"DISCOVER_SCREEN", ("255.255.255.255", 5000)) + data, address = sock.recvfrom(128) + if data == b"SCREEN_IP_80": + return address[0] + except TimeoutError: + return None + finally: + sock.close() + return None + + +def rpc(ip: str, request_id: int, method: str, params: dict | None = None) -> dict: + payload = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + } + if params is not None: + payload["params"] = params + + request = urllib.request.Request( + f"http://{ip}/api/mcp", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=5) as response: + return json.loads(response.read()) + + +def require_result(response: dict, label: str) -> None: + if "error" in response: + raise RuntimeError(f"{label} failed: {response['error']}") + if "result" not in response: + raise RuntimeError(f"{label} returned no result: {response}") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--ip", help="Device IP; omit to use UDP discovery") + parser.add_argument( + "--draw", + action="store_true", + help="Also clear the app canvas and draw visible test text", + ) + args = parser.parse_args() + + ip = args.ip or discover() + if not ip: + raise SystemExit("MCP Screen was not discovered. Pass --ip DEVICE_IP to test directly.") + + print(f"Testing MCP Screen at {ip}") + + tools = rpc(ip, 1, "tools/list") + require_result(tools, "tools/list") + names = [tool["name"] for tool in tools["result"]["tools"]] + expected = {"get_capabilities", "clear_screen", "draw_text"} + if not expected.issubset(names): + raise RuntimeError(f"Missing tools: {sorted(expected - set(names))}") + print(f"tools/list: {', '.join(names)}") + + capabilities = rpc( + ip, + 2, + "tools/call", + {"name": "get_capabilities", "arguments": {}}, + ) + require_result(capabilities, "get_capabilities") + print("get_capabilities:", capabilities["result"]["content"][0]["text"]) + + if args.draw: + cleared = rpc( + ip, + 3, + "tools/call", + {"name": "clear_screen", "arguments": {"color": 1}}, + ) + require_result(cleared, "clear_screen") + + drawn = rpc( + ip, + 4, + "tools/call", + { + "name": "draw_text", + "arguments": { + "text": "Tactility MCP is alive", + "x": 20, + "y": 30, + "size": 2, + }, + }, + ) + require_result(drawn, "draw_text") + print("Visible draw test sent.") + + print("Phase 1 smoke test passed.") + + +if __name__ == "__main__": + main()