#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_JSON_BODY_LIMIT (256 * 1024) #define MCP_RAW_BODY_LIMIT (512 * 1024) #define MCP_RESPONSE_LIMIT (64 * 1024) 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\"]" "}" "}," "{" "\"name\":\"draw_image\"," "\"description\":\"Draw a bridge-preprocessed binary PBM image.\"," "\"inputSchema\":{" "\"type\":\"object\"," "\"properties\":{" "\"pbm_base64\":{\"type\":\"string\"}," "\"x\":{\"type\":\"integer\",\"default\":0}," "\"y\":{\"type\":\"integer\",\"default\":0}," "\"dither\":{\"type\":\"boolean\",\"default\":true}" "}," "\"required\":[\"pbm_base64\"]" "}" "}," "{" "\"name\":\"draw_color_bmp\"," "\"description\":\"Draw an uncompressed 24-bit or 32-bit BMP image.\"," "\"inputSchema\":{" "\"type\":\"object\"," "\"properties\":{" "\"bmp_base64\":{\"type\":\"string\"}," "\"x\":{\"type\":\"integer\",\"default\":0}," "\"y\":{\"type\":\"integer\",\"default\":0}" "}," "\"required\":[\"bmp_base64\"]" "}" "}," "{" "\"name\":\"draw_raw_rgb565\"," "\"description\":\"Draw big-endian raw RGB565 pixels.\"," "\"inputSchema\":{" "\"type\":\"object\"," "\"properties\":{" "\"rgb565_base64\":{\"type\":\"string\"}," "\"x\":{\"type\":\"integer\"}," "\"y\":{\"type\":\"integer\"}," "\"w\":{\"type\":\"integer\"}," "\"h\":{\"type\":\"integer\"}" "}," "\"required\":[\"rgb565_base64\",\"x\",\"y\",\"w\",\"h\"]" "}" "}," "{" "\"name\":\"get_screenshot\"," "\"description\":\"Capture the MCP framebuffer as a PBM image.\"," "\"inputSchema\":{\"type\":\"object\",\"properties\":{}}" "}" "]"; static int base64_value(char character) { if (character >= 'A' && character <= 'Z') return character - 'A'; if (character >= 'a' && character <= 'z') return character - 'a' + 26; if (character >= '0' && character <= '9') return character - '0' + 52; if (character == '+') return 62; if (character == '/') return 63; return -1; } static uint8_t* base64_decode(const char* input, size_t* output_size) { if (input == NULL || output_size == NULL) { return NULL; } const char* payload = input; const char* marker = strstr(input, ";base64,"); if (marker != NULL) { payload = marker + 8; } size_t input_size = strlen(payload); uint8_t* output = malloc((input_size / 4) * 3 + 3); if (output == NULL) { return NULL; } uint32_t accumulator = 0; int bits = 0; size_t written = 0; for (size_t index = 0; index < input_size; ++index) { char character = payload[index]; if (character == '=') { break; } int value = base64_value(character); if (value < 0) { if (character == '\r' || character == '\n' || character == ' ' || character == '\t') { continue; } free(output); return NULL; } accumulator = (accumulator << 6) | (uint32_t)value; bits += 6; if (bits >= 8) { bits -= 8; output[written++] = (uint8_t)((accumulator >> bits) & 0xFF); } } *output_size = written; return output; } 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]; uint16_t width = state->draw_width > 0 ? state->draw_width : state->display_width; uint16_t height = state->draw_height > 0 ? state->draw_height : state->display_height; snprintf( capabilities, sizeof(capabilities), "{\"color\":true,\"width\":%u,\"height\":%u," "\"formats\":[\"rgb565_base64\",\"bmp_base64\",\"pbm_base64\"]," "\"platform\":\"tactility\",\"appVersion\":\"0.1.0\"," "\"uiVisible\":%s}", width, 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); } if (strcmp(name, "draw_raw_rgb565") == 0) { cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "rgb565_base64"); cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x"); cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y"); cJSON* width_item = cJSON_GetObjectItemCaseSensitive(arguments, "w"); cJSON* height_item = cJSON_GetObjectItemCaseSensitive(arguments, "h"); if (!cJSON_IsString(encoded_item) || !cJSON_IsNumber(x_item) || !cJSON_IsNumber(y_item) || !cJSON_IsNumber(width_item) || !cJSON_IsNumber(height_item)) { return make_error(id, -32602, "draw_raw_rgb565 requires rgb565_base64, x, y, w, and h"); } size_t decoded_size = 0; uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size); if (decoded == NULL) { return make_error(id, -32602, "Invalid RGB565 base64 data"); } bool drawn = mcp_ui_draw_rgb565_be( state, decoded, decoded_size, x_item->valueint, y_item->valueint, width_item->valueint, height_item->valueint ); free(decoded); return drawn ? make_tool_result(id, "Raw RGB565 data displayed successfully.") : make_error(id, -32010, "Failed to draw RGB565 data"); } if (strcmp(name, "draw_color_bmp") == 0) { cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "bmp_base64"); cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x"); cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y"); if (!cJSON_IsString(encoded_item)) { return make_error(id, -32602, "Missing bmp_base64"); } size_t decoded_size = 0; uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size); if (decoded == NULL) { return make_error(id, -32602, "Invalid BMP base64 data"); } bool drawn = mcp_ui_draw_bmp( state, decoded, decoded_size, cJSON_IsNumber(x_item) ? x_item->valueint : 0, cJSON_IsNumber(y_item) ? y_item->valueint : 0 ); free(decoded); return drawn ? make_tool_result(id, "Color BMP image displayed successfully.") : make_error(id, -32602, "Unsupported or invalid BMP image"); } if (strcmp(name, "draw_image") == 0) { cJSON* encoded_item = cJSON_GetObjectItemCaseSensitive(arguments, "pbm_base64"); cJSON* x_item = cJSON_GetObjectItemCaseSensitive(arguments, "x"); cJSON* y_item = cJSON_GetObjectItemCaseSensitive(arguments, "y"); if (!cJSON_IsString(encoded_item)) { return make_error( id, -32602, "draw_image requires bridge-preprocessed pbm_base64" ); } size_t decoded_size = 0; uint8_t* decoded = base64_decode(encoded_item->valuestring, &decoded_size); if (decoded == NULL) { return make_error(id, -32602, "Invalid PBM base64 data"); } bool drawn = mcp_ui_draw_pbm( state, decoded, decoded_size, cJSON_IsNumber(x_item) ? x_item->valueint : 0, cJSON_IsNumber(y_item) ? y_item->valueint : 0 ); free(decoded); return drawn ? make_tool_result(id, "Image displayed successfully.") : make_error(id, -32602, "Unsupported or invalid PBM image"); } if (strcmp(name, "get_screenshot") == 0) { char* encoded = mcp_ui_get_screenshot_pbm_base64(state); if (encoded == NULL) { return make_error(id, -32010, "Screenshot capture failed"); } size_t result_size = strlen(encoded) + 17; char* result = malloc(result_size); if (result == NULL) { free(encoded); return make_error(id, -32603, "Out of memory"); } snprintf(result, result_size, "__PBM_BASE64__:%s", encoded); cJSON* response = make_tool_result(id, result); free(result); free(encoded); return response; } 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 int query_integer(const char* path, const char* name, int default_value) { const char* query = strchr(path, '?'); if (query == NULL) { return default_value; } query++; size_t name_size = strlen(name); while (*query != '\0') { if (strncmp(query, name, name_size) == 0 && query[name_size] == '=') { return atoi(query + name_size + 1); } query = strchr(query, '&'); if (query == NULL) { break; } query++; } return default_value; } static void handle_http_client(McpScreenState* state, int client) { struct timeval timeout = { .tv_sec = 0, .tv_usec = 250000 }; setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); char* headers = calloc(1, MCP_HEADER_LIMIT + 1); if (headers == NULL) { send_http_response(client, 400, "text/plain", "Out of memory"); return; } int received = 0; int header_end = -1; while (state->running && received < MCP_HEADER_LIMIT) { int count = recv(client, headers + received, MCP_HEADER_LIMIT - received, 0); if (count <= 0) { free(headers); return; } received += count; header_end = find_header_end(headers, received); if (header_end >= 0) { break; } } if (!state->running) { free(headers); return; } if (header_end < 0) { send_http_response(client, 400, "text/plain", "Invalid HTTP headers"); free(headers); return; } headers[header_end - 4] = '\0'; char method[12] = {0}; char path[128] = {0}; if (sscanf(headers, "%11s %127s", method, path) != 2) { send_http_response(client, 400, "text/plain", "Invalid request line"); free(headers); return; } if (strcmp(method, "POST") != 0) { send_http_response(client, 405, "text/plain", "POST required"); free(headers); return; } bool is_mcp = strcmp(path, "/api/mcp") == 0; bool is_raw = strncmp(path, "/api/screen/raw", 15) == 0; if (!is_mcp && !is_raw) { send_http_response(client, 404, "text/plain", "Not found"); free(headers); return; } int body_limit = is_raw ? MCP_RAW_BODY_LIMIT : MCP_JSON_BODY_LIMIT; int content_length = parse_content_length(headers); if (content_length <= 0 || content_length > body_limit) { send_http_response( client, content_length > body_limit ? 413 : 400, "text/plain", "Invalid Content-Length" ); free(headers); return; } uint8_t* body = malloc((size_t)content_length + (is_mcp ? 1 : 0)); if (body == NULL) { send_http_response(client, 400, "text/plain", "Out of memory"); free(headers); return; } int body_received = received - header_end; if (body_received > content_length) { body_received = content_length; } memcpy(body, headers + header_end, body_received); free(headers); while (state->running && body_received < content_length) { int count = recv(client, body + body_received, content_length - body_received, 0); if (count <= 0) { send_http_response(client, 400, "text/plain", "Incomplete request body"); free(body); return; } body_received += count; } if (!state->running || body_received < content_length) { free(body); return; } if (is_raw) { int x = query_integer(path, "x", 0); int y = query_integer(path, "y", 0); int width = query_integer(path, "w", state->draw_width); int height = query_integer(path, "h", state->draw_height); bool valid_size = width > 0 && height > 0 && (size_t)width * height * 2 == (size_t)content_length; bool drawn = valid_size && mcp_ui_draw_rgb565_be( state, body, content_length, x, y, width, height ); send_http_response( client, drawn ? 200 : 400, "text/plain", drawn ? "OK" : "Invalid RGB565 payload" ); } else { body[content_length] = '\0'; cJSON* response = handle_rpc(state, (const char*)body); 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(body); } 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) { ESP_LOGE(TAG, "Service start rejected state=%p running=%d", state, state != NULL && state->running); return false; } /* * onHide only requests shutdown because it runs in Tactility's GUI * lifecycle. Reap the worker after it has reached its safe suspended * state before starting a new foreground server. */ if (state->http_task != NULL) { if (!state->http_exited) { ESP_LOGE(TAG, "Previous HTTP worker is still stopping"); return false; } vTaskDelete(state->http_task); state->http_task = NULL; } state->running = true; state->http_exited = false; state->http_socket = -1; state->client_socket = -1; 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_hide(McpScreenState* state) { if (state == NULL) { return; } state->running = false; state->http_ready = false; state->discovery_ready = false; } void mcp_services_stop(McpScreenState* state) { if (state == NULL) { return; } mcp_services_hide(state); for (int attempt = 0; attempt < 80; ++attempt) { if (state->http_task == NULL || state->http_exited) { break; } vTaskDelay(pdMS_TO_TICKS(10)); } if (state->http_task != NULL) { /* * Normal path: the non-blocking worker has closed its sockets and * suspended itself. The bounded fallback guarantees that no * external-ELF instruction survives onDestroy. */ vTaskDelete(state->http_task); state->http_task = NULL; } close_socket(&state->http_socket); close_socket(&state->client_socket); close_socket(&state->discovery_socket); state->http_exited = true; }