#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; } }