summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-07-04 17:21:32 +0900
committertanaka takashi <isazaka@gmail.com>2026-07-04 17:21:32 +0900
commit0634f2048c7c9a474aab1d8104b964b3bf70a064 (patch)
tree508c305a64cbd3b01f5146776e22865d283d5dbb
parenteb5e9d9f37bda7d78f69614da70402357794168a (diff)
feat(P1-002): migrate offline JSONL import/export to dedicated CLI
- Replace the MCP import_messages tool with standalone CLI commands: iny-convmem-mcp --data-dir <path> --import <file> iny-convmem-mcp --data-dir <path> --export <file> - Add src/cli/{import,export,cli.h} and a CTest process test (tiny-convmem-mcp.cli) covering round-trip, manifest generation, overwrite/reject policies, and include_deleted export. - Fix Debug heap corruption in iny_store_import() by deep-copying imported messages instead of taking ownership of caller-allocated records, which were later freed by the caller. - Remove the now-redundant src/mcp/tools/import_messages.c and keep the tool registry at 7 MCP tools. - Update docs/development-plan.md (P1-002 complete) and docs/implementation-status.md with CLI test coverage.
-rw-r--r--src/CMakeLists.txt3
-rw-r--r--src/app/main.c88
-rw-r--r--src/cli/cli.h21
-rw-r--r--src/cli/export.c98
-rw-r--r--src/cli/import.c (renamed from src/mcp/tools/import_messages.c)153
-rw-r--r--src/mcp/tools/common/registry.c3
-rw-r--r--src/mcp/tools/common/registry.h1
-rw-r--r--src/mcp/tools/read_messages.c100
-rw-r--r--src/storage/store.c16
-rw-r--r--tests/CMakeLists.txt11
-rw-r--r--tests/cli.cmake63
-rw-r--r--tests/fixtures/import-sample.jsonl3
-rw-r--r--tests/mcp_protocol_test.py22
13 files changed, 372 insertions, 210 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a8d09dc..256bbaa 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -21,6 +21,8 @@ endif()
add_executable(tiny-convmem-mcp
app/main.c
+ cli/import.c
+ cli/export.c
domain/message.c
domain/search.c
storage/codec.c
@@ -34,7 +36,6 @@ add_executable(tiny-convmem-mcp
mcp/tools/common/registry.c
mcp/tools/delete_message.c
mcp/tools/get_system_health.c
- mcp/tools/import_messages.c
mcp/tools/read_messages.c
mcp/tools/rebuild_index.c
mcp/tools/search_messages.c
diff --git a/src/app/main.c b/src/app/main.c
index f73b314..415e999 100644
--- a/src/app/main.c
+++ b/src/app/main.c
@@ -2,6 +2,7 @@
#include <stdlib.h>
#include <string.h>
+#include "cli/cli.h"
#include "mcp/server.h"
#include "storage/store.h"
#include "sys/system.h"
@@ -11,7 +12,75 @@
static void
usage(FILE *stream)
{
- fprintf(stream, "Usage: tiny-convmem-mcp --data-dir <path>\n");
+ fprintf(stream,
+ "Usage: tiny-convmem-mcp --data-dir <path> [command]\n"
+ "Commands:\n"
+ " (none) Run the MCP server (default)\n"
+ " --import <file> [--policy overwrite|reject]\n"
+ " Import a JSONL export into the store\n"
+ " --export <file> [--session <id>] [--include-deleted]\n"
+ " Export the store to a JSONL file (- for stdout)\n");
+}
+
+static const char *
+find_argument(const tiny_sys_arguments_t *arguments, const char *name)
+{
+ size_t index;
+ for (index = 1; index + 1 < arguments->count; ++index)
+ {
+ if (strcmp(arguments->values[index], name) == 0)
+ {
+ return arguments->values[index + 1];
+ }
+ }
+ return NULL;
+}
+
+static int
+flag_present(const tiny_sys_arguments_t *arguments, const char *name)
+{
+ size_t index;
+ for (index = 1; index < arguments->count; ++index)
+ {
+ if (strcmp(arguments->values[index], name) == 0)
+ {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+static int
+run_server(tiny_store_t *store)
+{
+ int result = tiny_convmem_server_run(stdin, stdout, store);
+ if (result != 0)
+ {
+ fprintf(stderr, "tiny-convmem-mcp stopped with error code %d\n", result);
+ }
+ return result;
+}
+
+static int
+run_cli_command(tiny_store_t *store, const tiny_sys_arguments_t *arguments)
+{
+ tiny_cli_options_t options = { 0 };
+ options.data_dir = arguments->values[2];
+ options.import_path = find_argument(arguments, "--import");
+ options.export_path = find_argument(arguments, "--export");
+ options.session_id = find_argument(arguments, "--session");
+ options.policy_name = find_argument(arguments, "--policy");
+ options.include_deleted = flag_present(arguments, "--include-deleted");
+
+ if (options.import_path)
+ {
+ return tiny_cli_import(store, &options);
+ }
+ if (options.export_path)
+ {
+ return tiny_cli_export(store, &options);
+ }
+ return 2;
}
static int
@@ -21,17 +90,26 @@ run(const tiny_sys_arguments_t *arguments)
tiny_store_t *store = NULL;
tiny_store_status_t status;
int result;
+ int server_mode = 0;
if (arguments->count == 2 && strcmp(arguments->values[1], "--help") == 0)
{
usage(stdout);
return 0;
}
- if (arguments->count != 3 || strcmp(arguments->values[1], "--data-dir") != 0
+ if (arguments->count < 3 || strcmp(arguments->values[1], "--data-dir") != 0
|| arguments->values[2][0] == '\0')
{
usage(stderr);
return 2;
}
+ server_mode = (arguments->count == 3)
+ || (arguments->count == 5 && strcmp(arguments->values[3], "--import") != 0
+ && strcmp(arguments->values[3], "--export") != 0);
+ if (!server_mode && arguments->count < 5)
+ {
+ usage(stderr);
+ return 2;
+ }
absolute = tiny_sys_path_absolute(arguments->values[2]);
if (absolute == NULL)
{
@@ -45,12 +123,8 @@ run(const tiny_sys_arguments_t *arguments)
fprintf(stderr, "unable to open conversation store: %s\n", tiny_store_status_name(status));
return status == TINY_STORE_BUSY ? 3 : 1;
}
- result = tiny_convmem_server_run(stdin, stdout, store);
+ result = server_mode ? run_server(store) : run_cli_command(store, arguments);
tiny_store_close(store);
- if (result != 0)
- {
- fprintf(stderr, "tiny-convmem-mcp stopped with error code %d\n", result);
- }
return result;
}
diff --git a/src/cli/cli.h b/src/cli/cli.h
new file mode 100644
index 0000000..1b173b4
--- /dev/null
+++ b/src/cli/cli.h
@@ -0,0 +1,21 @@
+#ifndef TINY_CONVMEM_CLI_H
+#define TINY_CONVMEM_CLI_H
+
+#include "storage/store.h"
+
+#define TINY_CLI_LINE_MAX (TINY_CONTENT_MAX + 4096U)
+
+typedef struct tiny_cli_options
+{
+ const char *data_dir;
+ const char *import_path;
+ const char *export_path;
+ const char *session_id;
+ const char *policy_name;
+ int include_deleted;
+} tiny_cli_options_t;
+
+int tiny_cli_import(tiny_store_t *store, const tiny_cli_options_t *options);
+int tiny_cli_export(tiny_store_t *store, const tiny_cli_options_t *options);
+
+#endif
diff --git a/src/cli/export.c b/src/cli/export.c
new file mode 100644
index 0000000..4288ba4
--- /dev/null
+++ b/src/cli/export.c
@@ -0,0 +1,98 @@
+#include "cli/cli.h"
+
+#include "mcp/tools/common/common.h"
+#include "storage/codec.h"
+#include "storage/store.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+int
+tiny_cli_export(tiny_store_t *store, const tiny_cli_options_t *options)
+{
+ size_t index;
+ size_t emitted = 0;
+ FILE *output = NULL;
+ tiny_json_buffer_t item;
+
+ if (!options || !options->export_path)
+ {
+ fprintf(stderr, "export: path is required\n");
+ return 1;
+ }
+
+ if (strcmp(options->export_path, "-") == 0)
+ {
+ output = stdout;
+ }
+ else
+ {
+ if (fopen_s(&output, options->export_path, "wb") != 0 || !output)
+ {
+ fprintf(stderr, "export: unable to open '%s'\n", options->export_path);
+ return 1;
+ }
+ }
+
+ if (fprintf(output, "{\"type\":\"tiny-convmem-manifest\",\"format_version\":2}\n") < 0)
+ {
+ fprintf(stderr, "export: failed to write manifest\n");
+ if (output != stdout)
+ {
+ fclose(output);
+ }
+ return 1;
+ }
+
+ for (index = 0; index < tiny_store_entry_count(store); ++index)
+ {
+ const tiny_store_entry_t *entry = tiny_store_entry(store, index);
+ int write_failed;
+ if (!entry)
+ {
+ continue;
+ }
+ if (entry->deleted && !options->include_deleted)
+ {
+ continue;
+ }
+ if (options->session_id && options->session_id[0]
+ && strcmp(entry->message.session_id, options->session_id) != 0)
+ {
+ continue;
+ }
+
+ tiny_json_init(&item);
+ 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);
+ }
+
+ write_failed = item.failed || fprintf(output, "%s\n", item.data ? item.data : "") < 0;
+ tiny_json_free(&item);
+ if (write_failed)
+ {
+ fprintf(stderr, "export: failed to write record\n");
+ if (output != stdout)
+ {
+ fclose(output);
+ }
+ return 1;
+ }
+ emitted++;
+ }
+
+ if (output != stdout)
+ {
+ fclose(output);
+ }
+ (void)emitted;
+ return 0;
+}
diff --git a/src/mcp/tools/import_messages.c b/src/cli/import.c
index c46b016..3ddb9e9 100644
--- a/src/mcp/tools/import_messages.c
+++ b/src/cli/import.c
@@ -1,15 +1,16 @@
-#include "mcp/tools/common/registry.h"
+#include "cli/cli.h"
-#include "mcp/tools/common/common.h"
-#include "mcp/tools/common/context.h"
+#include "domain/message.h"
+#include "mcp/json.h"
+#include "storage/store.h"
+
+#include "mjson.h"
+#include <stdio.h>
#include <stdlib.h>
#include <string.h>
-#include "mjson.h"
-#include "storage/store.h"
-
-#define IMPORT_MAX_LINE (TINY_CONTENT_MAX + 4096U)
+#define IMPORT_MAX_LINE TINY_CLI_LINE_MAX
typedef struct import_record_list
{
@@ -147,7 +148,6 @@ import_parse_string_array(const char *json,
*count = index;
if (index > 0 && offset != 0)
{
- /* mjson_next returned non-zero because of a parse/validation failure. */
return false;
}
return true;
@@ -455,108 +455,109 @@ import_parse_jsonl(const char *jsonl, import_record_list_t *records)
return manifest_seen;
}
-static void
-call_import_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result)
+int
+tiny_cli_import(tiny_store_t *store, const tiny_cli_options_t *options)
{
- 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;
+ tiny_import_policy_t policy = TINY_IMPORT_OVERWRITE;
+ char *jsonl = NULL;
+ FILE *file = NULL;
+ long file_size;
+ int result = 1;
- if (!context || !jsonl)
+ if (!options || !options->import_path)
{
- tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "invalid import_messages arguments");
- free(jsonl);
- free(policy_name);
- return;
+ fprintf(stderr, "import: path is required\n");
+ return 1;
}
- if (policy_name && policy_name[0])
+
+ if (options->policy_name && options->policy_name[0])
{
- if (strcmp(policy_name, "overwrite") == 0)
+ if (strcmp(options->policy_name, "overwrite") == 0)
{
policy = TINY_IMPORT_OVERWRITE;
}
- else if (strcmp(policy_name, "reject") == 0)
+ else if (strcmp(options->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;
+ fprintf(stderr, "import: invalid policy '%s'\n", options->policy_name);
+ return 1;
}
}
+
+ if (fopen_s(&file, options->import_path, "rb") != 0 || !file)
+ {
+ fprintf(stderr, "import: unable to open '%s'\n", options->import_path);
+ return 1;
+ }
+
+ if (fseek(file, 0, SEEK_END) != 0 || (file_size = ftell(file)) < 0
+ || fseek(file, 0, SEEK_SET) != 0)
+ {
+ fprintf(stderr, "import: unable to seek '%s'\n", options->import_path);
+ fclose(file);
+ return 1;
+ }
+
+ if ((size_t)file_size > IMPORT_MAX_LINE * 1024ULL * 1024ULL)
+ {
+ fprintf(stderr, "import: file too large\n");
+ fclose(file);
+ return 1;
+ }
+
+ jsonl = malloc((size_t)file_size + 1);
+ if (!jsonl)
+ {
+ fprintf(stderr, "import: out of memory\n");
+ fclose(file);
+ return 1;
+ }
+
+ if (fread(jsonl, 1, (size_t)file_size, file) != (size_t)file_size)
+ {
+ fprintf(stderr, "import: unable to read '%s'\n", options->import_path);
+ free(jsonl);
+ fclose(file);
+ return 1;
+ }
+ jsonl[file_size] = '\0';
+ fclose(file);
+
if (!import_parse_jsonl(jsonl, &records))
{
- tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "invalid import JSONL");
- import_record_list_free(&records);
+ fprintf(stderr, "import: invalid JSONL in '%s'\n", options->import_path);
free(jsonl);
- free(policy_name);
- return;
+ return 1;
}
- status = tiny_store_import(
- context->store, records.items, records.count, policy, &imported, &rejected);
+
+ status = tiny_store_import(store, records.items, records.count, policy, &imported, &rejected);
import_record_list_free(&records);
- tiny_json_init(&json);
+ free(jsonl);
+
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, "}");
+ printf("{\"imported\":%zu,\"rejected\":%zu}\n", imported, rejected);
+ fflush(stdout);
+ result = 0;
}
else if (status == TINY_STORE_CONFLICT)
{
- tiny_json_raw(&json,
- "{\"status\":\"ERROR\",\"error\":{\"code\":\"CONFLICT\","
- "\"message\":\"import conflict with existing records\"}}");
+ fprintf(stderr, "import: conflict with existing records\n");
}
else if (status == TINY_STORE_CORRUPT)
{
- tiny_json_raw(&json,
- "{\"status\":\"ERROR\",\"error\":{\"code\":\"CORRUPT_STORE\","
- "\"message\":\"import JSONL contained invalid records\"}}");
+ fprintf(stderr, "import: invalid records\n");
}
else
{
- tiny_json_raw(&json,
- "{\"status\":\"ERROR\",\"error\":{\"code\":\"INTERNAL_ERROR\","
- "\"message\":\"import failed\"}}");
+ fprintf(stderr, "import: failed\n");
}
- 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;
+ return result;
}
diff --git a/src/mcp/tools/common/registry.c b/src/mcp/tools/common/registry.c
index cc3fa22..1bdafc3 100644
--- a/src/mcp/tools/common/registry.c
+++ b/src/mcp/tools/common/registry.c
@@ -8,6 +8,5 @@ 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_import_messages(server);
+ && tiny_mcp_register_get_system_health(server);
}
diff --git a/src/mcp/tools/common/registry.h b/src/mcp/tools/common/registry.h
index c873869..0d67eec 100644
--- a/src/mcp/tools/common/registry.h
+++ b/src/mcp/tools/common/registry.h
@@ -11,6 +11,5 @@ 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/read_messages.c b/src/mcp/tools/read_messages.c
index 647e6f3..109becb 100644
--- a/src/mcp/tools/read_messages.c
+++ b/src/mcp/tools/read_messages.c
@@ -729,86 +729,6 @@ call_sessions_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, m
}
static void
-call_export_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mcpc_ucbr_t *result)
-{
- 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;
- size_t outer_size = 64;
- bool truncated = false;
- mcpc_errcode_t limit_status = mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"limit", &limit);
- if (!context || !valid || limit < 1 || limit > 1000
- || (limit_status != MCPC_EC_0 && limit_status != MCPC_EC_ANYDATA_NOTFOUND))
- {
- tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "invalid export view arguments");
- free(session);
- free(cursor);
- return;
- }
- start_index = index;
- tiny_json_init(&lines);
- if (index == 0)
- {
- tiny_json_raw(&lines, "{\"type\":\"tiny-convmem-manifest\",\"format_version\":2}\n");
- }
- for (; index < tiny_store_entry_count(context->store) && emitted < (size_t)limit; ++index)
- {
- 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 && !include_deleted)
- || (session && session[0] && strcmp(session, entry->message.session_id) != 0))
- {
- continue;
- }
- tiny_json_init(&item);
- 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)
- {
- truncated = true;
- tiny_json_free(&item);
- break;
- }
- tiny_json_raw_n(&lines, item.data, item.length);
- tiny_json_raw(&lines, "\n");
- emitted++;
- tiny_json_free(&item);
- }
- tiny_json_init(&json);
- tiny_json_raw(&json, "{\"jsonl\":");
- tiny_json_string(&json, lines.data ? lines.data : "");
- if (truncated || index < tiny_store_entry_count(context->store))
- {
- char next[32];
- snprintf(next, sizeof(next), "%zu", index > start_index ? index : start_index + 1);
- tiny_json_raw(&json, ",\"nextCursor\":");
- tiny_json_string(&json, next);
- tiny_json_raw(&json, ",\"truncated\":true");
- }
- tiny_json_raw(&json, "}");
- tiny_mcp_tool_add_json(result, &json);
- tiny_json_free(&json);
- tiny_json_free(&lines);
- free(session);
- free(cursor);
-}
-
-static void
call_read_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result)
{
tiny_mcp_tools_context_t *context = tiny_mcp_tools_context_current();
@@ -841,14 +761,6 @@ call_read_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result)
free(cursor_text);
return;
}
- if (view && strcmp(view, "export") == 0)
- {
- call_export_view(context, tool, result);
- free(view);
- free(session);
- free(cursor_text);
- return;
- }
if (view && strcmp(view, "history") != 0)
{
tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "unsupported read_messages view");
@@ -983,11 +895,7 @@ tiny_mcp_register_read_messages(mcpc_server_t *server)
call_read_messages);
return tool
&& tiny_mcp_tool_add_property(
- tool,
- "view",
- "Read view: history (default), context, sessions, or export.",
- MCPC_U8STR,
- false)
+ tool, "view", "Read view: history (default), context, or sessions.", MCPC_U8STR, false)
&& tiny_mcp_tool_add_property(tool,
"session_id",
"Conversation session whose chronological history to read.",
@@ -1022,11 +930,5 @@ 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;
}
diff --git a/src/storage/store.c b/src/storage/store.c
index 45621d1..5701578 100644
--- a/src/storage/store.c
+++ b/src/storage/store.c
@@ -1957,16 +1957,26 @@ tiny_store_import(tiny_store_t *store,
}
if (existing != NULL)
{
+ tiny_message_t replacement;
+ tiny_message_init(&replacement);
+ if (tiny_message_copy(&replacement, &record->message) != 0)
+ {
+ status = TINY_STORE_NO_MEMORY;
+ break;
+ }
tiny_message_free(&existing->message);
existing->deleted = record->deleted;
existing->sequence = (uint64_t)record->message.timestamp_ms;
- existing->message = record->message;
+ existing->message = replacement;
(*imported_count)++;
}
else
{
- if (!ensure_entries(store))
+ tiny_message_t copied;
+ tiny_message_init(&copied);
+ if (!ensure_entries(store) || tiny_message_copy(&copied, &record->message) != 0)
{
+ tiny_message_free(&copied);
status = TINY_STORE_NO_MEMORY;
break;
}
@@ -1974,7 +1984,7 @@ tiny_store_import(tiny_store_t *store,
&store->entries[store->entry_count], 0, sizeof(store->entries[store->entry_count]));
store->entries[store->entry_count].deleted = record->deleted;
store->entries[store->entry_count].sequence = (uint64_t)record->message.timestamp_ms;
- store->entries[store->entry_count].message = record->message;
+ store->entries[store->entry_count].message = copied;
store->entry_count++;
(*imported_count)++;
}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 0bc5d89..b0d86b2 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -115,6 +115,16 @@ add_test(
)
set_tests_properties(tiny-convmem-mcp.lifecycle PROPERTIES TIMEOUT 15)
+add_test(
+ NAME tiny-convmem-mcp.cli
+ COMMAND "${CMAKE_COMMAND}"
+ "-DSERVER=$<TARGET_FILE:tiny-convmem-mcp>"
+ "-DIMPORT_INPUT=${CMAKE_CURRENT_SOURCE_DIR}/fixtures/import-sample.jsonl"
+ "-DDATA_DIR=${CMAKE_CURRENT_BINARY_DIR}/cli-data"
+ -P "${CMAKE_CURRENT_SOURCE_DIR}/cli.cmake"
+)
+set_tests_properties(tiny-convmem-mcp.cli PROPERTIES TIMEOUT 15)
+
add_test(NAME tiny-convmem-mcp.requires-data-dir COMMAND $<TARGET_FILE:tiny-convmem-mcp>)
set_tests_properties(tiny-convmem-mcp.requires-data-dir PROPERTIES WILL_FAIL TRUE)
@@ -150,6 +160,7 @@ if(TINY_CONVMEM_ENABLE_ASAN AND MSVC)
tiny-convmem-mcp.sys
tiny-convmem-mcp.storage
tiny-convmem-mcp.lifecycle
+ tiny-convmem-mcp.cli
tiny-convmem-mcp.requires-data-dir
tiny-convmem-mcp.audit
APPEND PROPERTY ENVIRONMENT_MODIFICATION
diff --git a/tests/cli.cmake b/tests/cli.cmake
new file mode 100644
index 0000000..cc96018
--- /dev/null
+++ b/tests/cli.cmake
@@ -0,0 +1,63 @@
+if(NOT DEFINED SERVER OR NOT EXISTS "${SERVER}")
+ message(FATAL_ERROR "SERVER does not name a built tiny-convmem-mcp executable")
+endif()
+if(NOT DEFINED IMPORT_INPUT OR NOT EXISTS "${IMPORT_INPUT}")
+ message(FATAL_ERROR "IMPORT_INPUT does not name the import fixture")
+endif()
+if(NOT DEFINED DATA_DIR)
+ message(FATAL_ERROR "DATA_DIR is required")
+endif()
+
+file(REMOVE_RECURSE "${DATA_DIR}")
+
+# Import JSONL via CLI.
+execute_process(
+ COMMAND "${SERVER}" --data-dir "${DATA_DIR}" --import "${IMPORT_INPUT}"
+ OUTPUT_VARIABLE import_output
+ ERROR_VARIABLE import_error
+ RESULT_VARIABLE import_result
+)
+if(NOT import_result EQUAL 0)
+ message(FATAL_ERROR "CLI import failed: ${import_error}\n${import_output}")
+endif()
+if(NOT import_output MATCHES "\\{\"imported\":2,\"rejected\":0\\}")
+ message(FATAL_ERROR "unexpected import result: ${import_output}")
+endif()
+
+# Export the same data via CLI.
+set(export_path "${DATA_DIR}/exported.jsonl")
+execute_process(
+ COMMAND "${SERVER}" --data-dir "${DATA_DIR}" --export "${export_path}"
+ OUTPUT_VARIABLE export_output
+ ERROR_VARIABLE export_error
+ RESULT_VARIABLE export_result
+)
+if(NOT export_result EQUAL 0)
+ message(FATAL_ERROR "CLI export failed: ${export_error}\n${export_output}")
+endif()
+
+# Verify exported content includes the manifest and imported records.
+file(READ "${export_path}" exported)
+string(FIND "${exported}" "\"type\":\"tiny-convmem-manifest\"" manifest_position)
+string(FIND "${exported}" "imported via CLI" first_position)
+string(FIND "${exported}" "second imported message" second_position)
+if(manifest_position EQUAL -1 OR first_position EQUAL -1 OR second_position EQUAL -1)
+ message(FATAL_ERROR "exported JSONL missing expected records: ${exported}")
+endif()
+
+# Round-trip: re-import the exported file into a fresh store.
+file(REMOVE_RECURSE "${DATA_DIR}/roundtrip")
+execute_process(
+ COMMAND "${SERVER}" --data-dir "${DATA_DIR}/roundtrip" --import "${export_path}"
+ OUTPUT_VARIABLE roundtrip_output
+ ERROR_VARIABLE roundtrip_error
+ RESULT_VARIABLE roundtrip_result
+)
+if(NOT roundtrip_result EQUAL 0)
+ message(FATAL_ERROR "CLI round-trip import failed: ${roundtrip_error}\n${roundtrip_output}")
+endif()
+if(NOT roundtrip_output MATCHES "\\{\"imported\":2,\"rejected\":0\\}")
+ message(FATAL_ERROR "unexpected round-trip result: ${roundtrip_output}")
+endif()
+
+message(STATUS "CLI import/export round-trip succeeded") \ No newline at end of file
diff --git a/tests/fixtures/import-sample.jsonl b/tests/fixtures/import-sample.jsonl
new file mode 100644
index 0000000..4785749
--- /dev/null
+++ b/tests/fixtures/import-sample.jsonl
@@ -0,0 +1,3 @@
+{"type":"tiny-convmem-manifest","format_version":2}
+{"message_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","session_id":"import-test","role":"user","timestamp":1720843200000,"updated_at":1720843200000,"revision":1,"content":"imported via CLI","metadata":{"tags":["cli"],"memory_tier":"short","memory_kind":"conversation","importance":50,"relations":[],"source_message_ids":[]}}
+{"message_id":"01ARZ3NDEKTSV4RRFFQ69G5FAW","session_id":"import-test","role":"assistant","timestamp":1720843200001,"updated_at":1720843200001,"revision":1,"content":"second imported message","metadata":{"tags":["cli"],"memory_tier":"short","memory_kind":"conversation","importance":50,"relations":[],"source_message_ids":[]}} \ No newline at end of file
diff --git a/tests/mcp_protocol_test.py b/tests/mcp_protocol_test.py
index ec4f350..9074d9b 100644
--- a/tests/mcp_protocol_test.py
+++ b/tests/mcp_protocol_test.py
@@ -134,7 +134,6 @@ def main() -> None:
"delete_message",
"rebuild_index",
"get_system_health",
- "import_messages",
}
expected_descriptions = {
"write_message": "Append and durably persist a conversation message. Use it to store information that should remain available after server restarts.",
@@ -144,7 +143,6 @@ def main() -> None:
"delete_message": "Delete a persisted message so it is immediately excluded from reads and searches. Physical removal occurs after rebuild_index compacts the store.",
"rebuild_index": "Compact the store and rebuild its snapshot and in-memory indexes. Use it for maintenance or to physically remove logically deleted records; it may block other requests while running.",
"get_system_health": "Inspect storage health, recovery state, message counts, and file sizes. Use it to diagnose persistence or recovery problems.",
- "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.",
}
assert {name: tool["description"] for name, tool in tools.items()} == expected_descriptions
expected_property_descriptions = {
@@ -155,7 +153,7 @@ def main() -> None:
"metadata_json": "Optional JSON object string containing tool_call_id, tags, memory_tier, memory_kind, importance, summary, relations, and/or source_message_ids; defaults to {}.",
},
"read_messages": {
- "view": "Read view: history (default), context, sessions, or export.",
+ "view": "Read view: history (default), context, or sessions.",
"session_id": "Conversation session whose chronological history to read.",
"message_id": "Message ULID required by the context view.",
"before": "Context messages before the target, from 0 to 10.",
@@ -164,7 +162,6 @@ def main() -> None:
"relation_types_json": "Optional JSON array string of relation types to follow.",
"limit": "Maximum messages to return in this page, from 1 to 1000; defaults to 100.",
"cursor": "Opaque nextCursor returned by a previous read_messages call; omit for the first page.",
- "include_deleted": "When true, export view includes deleted records as DEL lines; other views ignore this option.",
},
"search_messages": {
"query": "UTF-8 text to match against persisted message content and tags.",
@@ -184,10 +181,6 @@ def main() -> None:
"metadata_json": "Replacement metadata JSON; omit to retain metadata.",
"expected_revision": "Optional positive revision for optimistic concurrency.",
},
- "import_messages": {
- "jsonl": "JSONL string containing a manifest line followed by PUT message lines and optional DEL tombstone lines.",
- "policy": "Conflict policy: overwrite (default) replaces existing message IDs, reject refuses duplicate IDs.",
- },
}
for tool_name, descriptions in expected_property_descriptions.items():
properties = tools[tool_name]["inputSchema"]["properties"]
@@ -274,19 +267,6 @@ def main() -> None:
)
next_id += 1
assert any(item["session_id"] == "target" for item in sessions_view["sessions"])
- exported = tool_payload(
- tool_call(
- server,
- next_id,
- "read_messages",
- {"view": "export", "session_id": "target", "limit": 100},
- )
- )
- next_id += 1
- export_lines = exported["jsonl"].splitlines()
- assert json.loads(export_lines[0])["format_version"] == 2
- assert len(export_lines) == 21
- assert all(json.loads(line)["session_id"] == "target" for line in export_lines[1:])
io_after_views = tool_payload(tool_call(server, next_id, "get_system_health", {}))["io"]
next_id += 1
assert io_after_views == io_before