feat: add Tactility MCP Screen foundation

This commit is contained in:
Adolfo Reyna
2026-06-24 23:48:21 -04:00
parent fb800bb5bd
commit ac9d6dc0d3
9 changed files with 1057 additions and 1 deletions
+516
View File
@@ -0,0 +1,516 @@
#include "McpScreen.h"
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <cJSON.h>
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <lwip/inet.h>
#include <lwip/sockets.h>
#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;
}
}