diff options
Diffstat (limited to 'src/mcp/tools')
| -rw-r--r-- | src/mcp/tools/common/registry.c | 3 | ||||
| -rw-r--r-- | src/mcp/tools/common/registry.h | 4 | ||||
| -rw-r--r-- | src/mcp/tools/import_messages.c | 562 | ||||
| -rw-r--r-- | src/mcp/tools/read_messages.c | 21 |
4 files changed, 586 insertions, 4 deletions
diff --git a/src/mcp/tools/common/registry.c b/src/mcp/tools/common/registry.c index 1bdafc3..cc3fa22 100644 --- a/src/mcp/tools/common/registry.c +++ b/src/mcp/tools/common/registry.c @@ -8,5 +8,6 @@ tiny_mcp_tools_register(mcpc_server_t *server) return tiny_mcp_register_write_message(server) && tiny_mcp_register_read_messages(server) && tiny_mcp_register_search_messages(server) && tiny_mcp_register_update_message(server) && tiny_mcp_register_delete_message(server) && tiny_mcp_register_rebuild_index(server) - && tiny_mcp_register_get_system_health(server); + && tiny_mcp_register_get_system_health(server) + && tiny_mcp_register_import_messages(server); } diff --git a/src/mcp/tools/common/registry.h b/src/mcp/tools/common/registry.h index 451d300..c873869 100644 --- a/src/mcp/tools/common/registry.h +++ b/src/mcp/tools/common/registry.h @@ -1,14 +1,16 @@ + #ifndef TINY_CONVMEM_MCP_TOOLS_REGISTRY_H #define TINY_CONVMEM_MCP_TOOLS_REGISTRY_H #include <mcpc/mcpc.h> bool tiny_mcp_register_write_message(mcpc_server_t *server); -bool tiny_mcp_register_update_message(mcpc_server_t *server); bool tiny_mcp_register_read_messages(mcpc_server_t *server); bool tiny_mcp_register_search_messages(mcpc_server_t *server); +bool tiny_mcp_register_update_message(mcpc_server_t *server); bool tiny_mcp_register_delete_message(mcpc_server_t *server); bool tiny_mcp_register_rebuild_index(mcpc_server_t *server); bool tiny_mcp_register_get_system_health(mcpc_server_t *server); +bool tiny_mcp_register_import_messages(mcpc_server_t *server); #endif diff --git a/src/mcp/tools/import_messages.c b/src/mcp/tools/import_messages.c new file mode 100644 index 0000000..c46b016 --- /dev/null +++ b/src/mcp/tools/import_messages.c @@ -0,0 +1,562 @@ +#include "mcp/tools/common/registry.h" + +#include "mcp/tools/common/common.h" +#include "mcp/tools/common/context.h" + +#include <stdlib.h> +#include <string.h> + +#include "mjson.h" +#include "storage/store.h" + +#define IMPORT_MAX_LINE (TINY_CONTENT_MAX + 4096U) + +typedef struct import_record_list +{ + tiny_import_record_t *items; + size_t count; + size_t capacity; +} import_record_list_t; + +static void +import_record_list_free(import_record_list_t *list) +{ + size_t index; + if (!list) + { + return; + } + for (index = 0; index < list->count; ++index) + { + tiny_message_free(&list->items[index].message); + } + free(list->items); + list->items = NULL; + list->count = 0; + list->capacity = 0; +} + +static bool +import_record_list_push(import_record_list_t *list) +{ + tiny_import_record_t *resized; + size_t capacity; + if (list->count < list->capacity) + { + return true; + } + capacity = list->capacity == 0 ? 32 : list->capacity * 2; + resized = realloc(list->items, capacity * sizeof(*resized)); + if (!resized) + { + return false; + } + list->items = resized; + list->capacity = capacity; + return true; +} + +static bool +import_get_string(const char *json, int len, const char *path, char *out, size_t out_size) +{ + int decoded = mjson_get_string(json, len, path, out, (int)out_size); + return decoded > 0 && (size_t)decoded < out_size; +} + +static bool +import_get_i64(const char *json, int len, const char *path, int64_t *value) +{ + double number; + if (mjson_get_number(json, len, path, &number) != 1) + { + return false; + } + *value = (int64_t)number; + return true; +} + +static bool +import_get_u64(const char *json, int len, const char *path, uint64_t *value) +{ + double number; + if (mjson_get_number(json, len, path, &number) != 1 || number < 0) + { + return false; + } + *value = (uint64_t)number; + return true; +} + +static bool +import_parse_string_array(const char *json, + int len, + const char *path, + char ***output, + size_t *count, + size_t max_count, + size_t max_length) +{ + int offset = 0, key_offset, key_length, value_offset, value_length, value_type; + char *array_json = NULL; + int array_len = 0; + size_t index = 0; + *output = NULL; + *count = 0; + if (mjson_find(json, len, path, &array_json, &array_len) == 0) + { + return true; + } + if (array_json == NULL || mjson_find(array_json, array_len, "$", NULL, NULL) != MJSON_TOK_ARRAY) + { + return false; + } + *output = calloc(max_count, sizeof(char *)); + if (!*output) + { + return false; + } + while ((offset = mjson_next(array_json, + array_len, + offset, + &key_offset, + &key_length, + &value_offset, + &value_length, + &value_type)) + != 0) + { + char value[512]; + int decoded; + if (value_type != MJSON_TOK_STRING || index >= max_count + || (decoded = mjson_get_string( + array_json + value_offset, value_length, "$", value, sizeof(value))) + <= 0 + || (size_t)decoded > max_length || decoded >= (int)sizeof(value)) + { + break; + } + (*output)[index] = malloc((size_t)decoded + 1); + if (!(*output)[index]) + { + break; + } + memcpy((*output)[index], value, (size_t)decoded); + (*output)[index][decoded] = '\0'; + index++; + } + *count = index; + if (index > 0 && offset != 0) + { + /* mjson_next returned non-zero because of a parse/validation failure. */ + return false; + } + return true; +} + +static bool +import_parse_relations(const char *json, int len, const char *path, tiny_metadata_t *metadata) +{ + int offset = 0, key_offset, key_length, value_offset, value_length, value_type; + char *array_json = NULL; + int array_len = 0; + size_t index = 0; + if (mjson_find(json, len, path, &array_json, &array_len) == 0) + { + return true; + } + if (array_json == NULL || mjson_find(array_json, array_len, "$", NULL, NULL) != MJSON_TOK_ARRAY) + { + return false; + } + metadata->relations = calloc(TINY_RELATION_COUNT_MAX, sizeof(tiny_relation_t)); + if (!metadata->relations) + { + return false; + } + while ((offset = mjson_next(array_json, + array_len, + offset, + &key_offset, + &key_length, + &value_offset, + &value_length, + &value_type)) + != 0) + { + char type_name[32]; + char message_id[32]; + int decoded; + tiny_relation_t *relation; + if (value_type != MJSON_TOK_OBJECT || index >= TINY_RELATION_COUNT_MAX + || mjson_get_string( + array_json + value_offset, value_length, "$.type", type_name, sizeof(type_name)) + <= 0 + || (decoded = mjson_get_string(array_json + value_offset, + value_length, + "$.message_id", + message_id, + sizeof(message_id))) + <= 0) + { + return false; + } + relation = &metadata->relations[index]; + if (!tiny_relation_type_parse(type_name, &relation->type) + || !tiny_message_id_valid(message_id)) + { + return false; + } + memcpy(relation->message_id, message_id, (size_t)decoded); + relation->message_id[decoded] = '\0'; + index++; + } + metadata->relation_count = index; + return true; +} + +static bool +import_parse_metadata(const char *json, int len, tiny_message_t *message) +{ + char tier_name[32] = { 0 }; + char kind_name[32] = { 0 }; + char tool_call_id[TINY_TOOL_CALL_ID_MAX + 1] = { 0 }; + char summary[TINY_SUMMARY_MAX + 1] = { 0 }; + double importance_number = 0; + bool has_tool_call_id, has_summary, has_importance; + has_tool_call_id = mjson_find(json, len, "$.metadata.tool_call_id", NULL, NULL) != 0 + && import_get_string( + json, len, "$.metadata.tool_call_id", tool_call_id, sizeof(tool_call_id)); + has_summary = mjson_find(json, len, "$.metadata.summary", NULL, NULL) != 0 + && import_get_string(json, len, "$.metadata.summary", summary, sizeof(summary)); + has_importance = mjson_get_number(json, len, "$.metadata.importance", &importance_number) == 1; + if (!import_get_string(json, len, "$.metadata.memory_tier", tier_name, sizeof(tier_name)) + || !import_get_string(json, len, "$.metadata.memory_kind", kind_name, sizeof(kind_name)) + || !import_parse_string_array(json, + len, + "$.metadata.tags", + &message->metadata.tags, + &message->metadata.tag_count, + TINY_TAG_COUNT_MAX, + TINY_TAG_MAX) + || !import_parse_relations(json, len, "$.metadata.relations", &message->metadata) + || !import_parse_string_array(json, + len, + "$.metadata.source_message_ids", + &message->metadata.source_message_ids, + &message->metadata.source_count, + TINY_SOURCE_COUNT_MAX, + TINY_MESSAGE_ID_LENGTH)) + { + return false; + } + if (!tiny_tier_parse(tier_name, &message->metadata.memory_tier) + || !tiny_kind_parse(kind_name, &message->metadata.memory_kind)) + { + return false; + } + if (has_tool_call_id) + { + message->metadata.tool_call_id = malloc(strlen(tool_call_id) + 1); + if (!message->metadata.tool_call_id) + { + return false; + } + memcpy(message->metadata.tool_call_id, tool_call_id, strlen(tool_call_id) + 1); + } + if (has_summary) + { + message->metadata.summary = malloc(strlen(summary) + 1); + if (!message->metadata.summary) + { + return false; + } + memcpy(message->metadata.summary, summary, strlen(summary) + 1); + } + if (has_importance) + { + if (importance_number < 0 || importance_number > 100) + { + return false; + } + message->metadata.importance = (uint8_t)importance_number; + } + return true; +} + +static bool +import_manifest_valid(const char *line, int len) +{ + char type_name[64]; + double version = 0; + if (!import_get_string(line, len, "$.type", type_name, sizeof(type_name)) + || strcmp(type_name, "tiny-convmem-manifest") != 0 + || mjson_get_number(line, len, "$.format_version", &version) != 1) + { + return false; + } + return (int)version == 2; +} + +static bool +import_parse_del_record(const char *line, int len, tiny_import_record_t *record) +{ + char type_name[32]; + tiny_message_init(&record->message); + if (!import_get_string(line, len, "$.type", type_name, sizeof(type_name)) + || strcmp(type_name, "DEL") != 0 + || !import_get_string( + line, len, "$.message_id", record->message.message_id, sizeof(record->message.message_id)) + || !tiny_message_id_valid(record->message.message_id)) + { + return false; + } + record->deleted = true; + return true; +} + +static bool +import_parse_message_record(const char *line, int len, tiny_import_record_t *record) +{ + char role_name[32]; + char session_id[TINY_SESSION_ID_MAX + 1]; + char content_buffer[4096]; + tiny_message_init(&record->message); + record->deleted = false; + if (!import_get_string( + line, len, "$.message_id", record->message.message_id, sizeof(record->message.message_id)) + || !import_get_string(line, len, "$.session_id", session_id, sizeof(session_id)) + || !import_get_string(line, len, "$.role", role_name, sizeof(role_name)) + || !import_get_i64(line, len, "$.timestamp", &record->message.timestamp_ms) + || !import_get_i64(line, len, "$.updated_at", &record->message.updated_at_ms) + || !import_get_u64(line, len, "$.revision", &record->message.revision)) + { + return false; + } + if (!tiny_message_id_valid(record->message.message_id) + || !tiny_role_parse(role_name, &record->message.role) || record->message.timestamp_ms < 0 + || record->message.updated_at_ms < 0 || record->message.revision == 0 + || record->message.revision > INT32_MAX) + { + return false; + } + if (mjson_find(line, len, "$.content", NULL, NULL) == 0) + { + return false; + } + if (mjson_get_string(line, len, "$.content", content_buffer, sizeof(content_buffer)) > 0) + { + size_t length = strlen(content_buffer); + if (length == 0 || length > TINY_CONTENT_MAX) + { + return false; + } + record->message.content = malloc(length + 1); + if (!record->message.content) + { + return false; + } + memcpy(record->message.content, content_buffer, length + 1); + } + else + { + const char *content = NULL; + int content_len = 0; + if (mjson_find(line, len, "$.content", &content, &content_len) == 0 || !content + || content_len < 2) + { + return false; + } + if ((size_t)(content_len - 2) == 0 || (size_t)(content_len - 2) > TINY_CONTENT_MAX) + { + return false; + } + record->message.content = malloc((size_t)content_len - 1); + if (!record->message.content) + { + return false; + } + memcpy(record->message.content, content + 1, (size_t)content_len - 2); + record->message.content[(size_t)content_len - 2] = '\0'; + } + if (!tiny_utf8_valid(record->message.content, strlen(record->message.content))) + { + return false; + } + record->message.session_id = malloc(strlen(session_id) + 1); + if (!record->message.session_id) + { + return false; + } + memcpy(record->message.session_id, session_id, strlen(session_id) + 1); + if (!import_parse_metadata(line, len, &record->message) + || !tiny_metadata_valid(&record->message.metadata)) + { + tiny_message_free(&record->message); + tiny_message_init(&record->message); + return false; + } + return true; +} + +static bool +import_parse_jsonl(const char *jsonl, import_record_list_t *records) +{ + const char *line_start = jsonl; + const char *line_end; + bool manifest_seen = false; + while (*line_start) + { + int length; + tiny_import_record_t record; + line_end = strchr(line_start, '\n'); + if (line_end == NULL) + { + line_end = line_start + strlen(line_start); + } + length = (int)(line_end - line_start); + if (length > 0) + { + if (!manifest_seen) + { + if (!import_manifest_valid(line_start, length)) + { + return false; + } + manifest_seen = true; + } + else if (import_parse_del_record(line_start, length, &record)) + { + if (!import_record_list_push(records)) + { + return false; + } + records->items[records->count++] = record; + } + else if (import_parse_message_record(line_start, length, &record)) + { + if (!import_record_list_push(records)) + { + tiny_message_free(&record.message); + return false; + } + records->items[records->count++] = record; + } + else + { + return false; + } + } + if (*line_end == '\0') + { + break; + } + line_start = line_end + 1; + } + return manifest_seen; +} + +static void +call_import_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result) +{ + tiny_mcp_tools_context_t *context = tiny_mcp_tools_context_current(); + char *jsonl = tiny_mcp_tool_get_string(tool, "jsonl", TINY_CONTENT_MAX); + char *policy_name = tiny_mcp_tool_get_string(tool, "policy", 16); + tiny_import_policy_t policy = TINY_IMPORT_OVERWRITE; + import_record_list_t records = { NULL, 0, 0 }; + size_t imported = 0, rejected = 0; + tiny_json_buffer_t json; + tiny_store_status_t status; + + if (!context || !jsonl) + { + tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "invalid import_messages arguments"); + free(jsonl); + free(policy_name); + return; + } + if (policy_name && policy_name[0]) + { + if (strcmp(policy_name, "overwrite") == 0) + { + policy = TINY_IMPORT_OVERWRITE; + } + else if (strcmp(policy_name, "reject") == 0) + { + policy = TINY_IMPORT_REJECT; + } + else + { + tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "invalid import policy"); + free(jsonl); + free(policy_name); + return; + } + } + if (!import_parse_jsonl(jsonl, &records)) + { + tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "invalid import JSONL"); + import_record_list_free(&records); + free(jsonl); + free(policy_name); + return; + } + status = tiny_store_import( + context->store, records.items, records.count, policy, &imported, &rejected); + import_record_list_free(&records); + tiny_json_init(&json); + if (status == TINY_STORE_OK) + { + tiny_json_raw(&json, "{\"status\":\"OK\",\"imported\":"); + tiny_json_u64(&json, (uint64_t)imported); + tiny_json_raw(&json, ",\"rejected\":"); + tiny_json_u64(&json, (uint64_t)rejected); + tiny_json_raw(&json, "}"); + } + else if (status == TINY_STORE_CONFLICT) + { + tiny_json_raw(&json, + "{\"status\":\"ERROR\",\"error\":{\"code\":\"CONFLICT\"," + "\"message\":\"import conflict with existing records\"}}"); + } + else if (status == TINY_STORE_CORRUPT) + { + tiny_json_raw(&json, + "{\"status\":\"ERROR\",\"error\":{\"code\":\"CORRUPT_STORE\"," + "\"message\":\"import JSONL contained invalid records\"}}"); + } + else + { + tiny_json_raw(&json, + "{\"status\":\"ERROR\",\"error\":{\"code\":\"INTERNAL_ERROR\"," + "\"message\":\"import failed\"}}"); + } + tiny_mcp_tool_add_json(result, &json); + tiny_json_free(&json); + free(jsonl); + free(policy_name); +} + +bool +tiny_mcp_register_import_messages(mcpc_server_t *server) +{ + mcpc_tool_t *tool = tiny_mcp_tool_new( + "import_messages", + "Atomically import a JSONL export from read_messages view=export. The manifest line is " + "required; subsequent lines are PUT messages or DEL tombstones. Conflicting message IDs " + "are resolved by the policy argument.", + call_import_messages); + return tool + && tiny_mcp_tool_add_property(tool, + "jsonl", + "JSONL string containing a manifest line followed by PUT " + "message lines and optional DEL tombstone lines.", + MCPC_U8STR, + true) + && tiny_mcp_tool_add_property(tool, + "policy", + "Conflict policy: overwrite (default) replaces existing " + "message IDs, reject refuses duplicate IDs.", + MCPC_U8STR, + false) + && mcpc_server_add_tool(server, tool) == MCPC_EC_0; +} diff --git a/src/mcp/tools/read_messages.c b/src/mcp/tools/read_messages.c index 079e6eb..647e6f3 100644 --- a/src/mcp/tools/read_messages.c +++ b/src/mcp/tools/read_messages.c @@ -734,6 +734,8 @@ call_export_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mcp char *session = tiny_mcp_tool_get_string(tool, "session_id", TINY_SESSION_ID_MAX); char *cursor = tiny_mcp_tool_get_string(tool, "cursor", CURSOR_TEXT_MAX); int32_t limit = 500; + char *include_deleted_text = tiny_mcp_tool_get_string(tool, "include_deleted", 8); + bool include_deleted = include_deleted_text && strcmp(include_deleted_text, "true") == 0; bool valid; size_t index = simple_cursor(cursor, &valid), emitted = 0, start_index; tiny_json_buffer_t json, lines; @@ -759,13 +761,22 @@ call_export_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mcp const tiny_store_entry_t *entry = tiny_store_entry(context->store, index); tiny_json_buffer_t item; size_t line_overhead; - if (!entry || entry->deleted + if (!entry || (entry->deleted && !include_deleted) || (session && session[0] && strcmp(session, entry->message.session_id) != 0)) { continue; } tiny_json_init(&item); - tiny_mcp_tool_append_message(&item, &entry->message); + if (entry->deleted) + { + tiny_json_raw(&item, "{\"type\":\"DEL\",\"message_id\":"); + tiny_json_string(&item, entry->message.message_id); + tiny_json_raw(&item, "}"); + } + else + { + tiny_mcp_tool_append_message(&item, &entry->message); + } line_overhead = item.length + 1; if (item.failed || outer_size + lines.length + line_overhead + 2 > READ_RESPONSE_BUDGET) { @@ -1011,5 +1022,11 @@ tiny_mcp_register_read_messages(mcpc_server_t *server) "omit for the first page.", MCPC_U8STR, false) + && tiny_mcp_tool_add_property(tool, + "include_deleted", + "When true, export view includes deleted records as DEL " + "lines; other views ignore this option.", + MCPC_U8STR, + false) && mcpc_server_add_tool(server, tool) == MCPC_EC_0; } |
