summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-29 09:15:24 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-29 09:15:24 +0900
commit5b063df8868ef8cdc2a6aab7a670f911bc0ea9a4 (patch)
treead42c80733be664ccee5dcf3211ba3bb63328acf
parentd57577637c1c1a16176d63d5a89e9eec3c1e4d7c (diff)
feat: read_messagesに本文範囲取得viewを追加HEADv0.1.2main
大きなメッセージをMCPホストのtool result上限に触れず取得できるよう、read_messagesへcontent viewを追加する。UTF-8 byte単位のcontent_offset/content_limitとnext_content_offsetにより、文字境界を壊さず反復取得できる。 各chunkにrevisionとtotal_bytesを返し、expected_revisionで取得途中の更新をCONFLICTとして検出する。未知または削除済みmessageはNOT_FOUND、不正な範囲はVALIDATION_ERRORとする。読み取りには既存memory indexを利用し、永続data、revision、I/O counterを変更しない。 新規MCP toolは増やさず、従来の7 tool構成とhistory/context/sessionsの契約を維持する。storage recordとsnapshot形式も変更しないためmigrationとrebuildは不要。 README、設計書、MCPリファレンスを更新し、多言語UTF-8本文の完全再構成、境界・limit・revision・削除済みmessage、read-only I/O、既存view回帰をprotocol testで検証する。 Tests: ctest --test-dir build/range-read-test -C Release --output-on-failure (20/20 passed)
-rw-r--r--README.md2
-rw-r--r--docs/Design.md12
-rw-r--r--docs/mcp.md41
-rw-r--r--src/mcp/tools/read_messages.c152
-rw-r--r--tests/function-coverage.md2
-rw-r--r--tests/mcp_protocol_test.py228
6 files changed, 428 insertions, 9 deletions
diff --git a/README.md b/README.md
index e86c8f9..20310b6 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ MCPクライアントのstdio server設定へ、実行ファイルと保存先ã‚
| Tool | 用途 |
|---|---|
| `write_message` | session、role、contentと任意metadataを新しいメッセージとして保存します。 |
-| `read_messages` | session履歴、session一覧、対象メッセージ前後の文脈を取得します。 |
+| `read_messages` | session履歴、本文の範囲、session一覧、対象メッセージ前後の文脈を取得します。 |
| `search_messages` | content、summary、tagを検索し、score内訳や任意の前後文脈を返します。 |
| `update_message` | IDや作成日時を維持したまま、本文またはmetadataを更新します。 |
| `delete_message` | メッセージをread/search結果から除外します。物理除去はrebuild後です。 |
diff --git a/docs/Design.md b/docs/Design.md
index f370b74..b9a05c7 100644
--- a/docs/Design.md
+++ b/docs/Design.md
@@ -96,12 +96,24 @@ content または metadata を部分更新する。`expected_revision` が一致
次の view を持つ。
- `history`: session の時系列ページ。安定 cursor を返す
+- `content`: message contentをUTF-8 byte offsetとbounded limitで分割取得する
- `context`: target の前後メッセージと incoming/outgoing relation を返す。relation depth は 0~2
- `sessions`: session ごとの件数、kind 集計、代表 summary、最終時刻を返す
- `export`: versioned JSONL をページ出力する互換 view
レスポンスは 8 MiB budget を超えない位置でページを区切る。
+`content` viewは既定4096 bytes、最大65536 bytesを返す。offsetは0、末尾、またはUTF-8
+code point境界だけを受理し、limit位置がcode point途中なら直前の境界まで戻す。limitが
+1 code pointより小さい場合だけ、そのcode pointを完全に返して進捗を保証する。各responseの
+`revision`と`total_bytes`を返し、2回目以降の`expected_revision`で途中更新を`CONFLICT`として
+検出する。このviewは永続data、access履歴、revisionを変更しない。message検索に必要な既存の
+in-memory indexは、他のread viewと同様に必要時だけ構築する。
+
+このview追加は既存toolへのadditive schema changeだけである。既存viewのinput、response、
+cursor semanticsと、保存recordおよびsnapshotのformat versionは変更しないため、data migration
+とrebuildは不要であり、旧binaryと新binaryは同じdataを読み込める。
+
### `search_messages`
content、summary、tags を対象に検索する。session、kind、tier、tag、minimum importance で絞り込める。matched fields、score と field 別 score 内訳、任意の前後 context を返す。
diff --git a/docs/mcp.md b/docs/mcp.md
index 788fc84..e7b959d 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -143,13 +143,13 @@ messageのidentity、session、role、作成時刻を維持し、contentまたã�
## `read_messages`
-`view`によりhistory、context、sessionsの3種類を返す。`view`省略時は`history`。
+`view`によりhistory、content、context、sessionsの4種類を返す。`view`省略時は`history`。
### 共通Input
| Parameter | 必須 | 型 | 説明 |
|---|---:|---|---|
-| `view` | No | string | `history` / `context` / `sessions` |
+| `view` | No | string | `history` / `content` / `context` / `sessions` |
| `limit` | No | integer | 1~1000、default 100 |
| `cursor` | No | string | 前回responseの`nextCursor`。内容はopaque |
@@ -174,6 +174,43 @@ session内のlive messageを作成順に返す。
続きを読む場合は同じ`session_id`と`nextCursor`を渡す。
+### `content` view
+
+live message 1件のcontentを、保存状態を変更せずにUTF-8 byte rangeで取得する。MCP hostの
+tool-result上限を避けるため、`next_content_offset`を使って末尾まで反復する。
+
+追加parameter:
+
+| Parameter | 必須 | 型 | Default | 説明 |
+|---|---:|---|---:|---|
+| `message_id` | Yes | string | — | 対象の26文字ULID |
+| `content_offset` | No | integer | 0 | UTF-8 byte offset。0、末尾、またはcode point境界 |
+| `content_limit` | No | integer | 4096 | 目標chunk bytes、1~65536 |
+| `expected_revision` | No | integer | — | 指定時は現在revisionとの一致を要求 |
+
+```json
+{
+ "content_range": {
+ "message_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
+ "revision": 1,
+ "total_bytes": 14420,
+ "content_offset": 4096,
+ "next_content_offset": 8191,
+ "complete": false,
+ "content": "..."
+ }
+}
+```
+
+`complete`がfalseなら、同じ`message_id`、返された`next_content_offset`、最初のresponseで得た
+`revision`を`expected_revision`として次を取得する。limit位置がUTF-8 code point途中なら、
+通常は直前の境界まで短くする。limitが1 code pointより小さい場合だけ、その1文字を完全に
+返すため最大3 bytes超えることがある。
+
+messageが存在しないか削除済みなら`NOT_FOUND`、offsetが範囲外またはUTF-8境界でなければ
+`VALIDATION_ERROR`、`expected_revision`不一致なら`CONFLICT`を返す。このviewはlogical I/O
+counterを含む永続状態を変更しない。
+
### `context` view
指定message、同一sessionの前後message、relation graphを返す。
diff --git a/src/mcp/tools/read_messages.c b/src/mcp/tools/read_messages.c
index 131c513..e217aa2 100644
--- a/src/mcp/tools/read_messages.c
+++ b/src/mcp/tools/read_messages.c
@@ -15,6 +15,8 @@
#define READ_DEFAULT_LIMIT 100
#define READ_MAX_LIMIT 1000
#define READ_RESPONSE_BUDGET (8U * 1024U * 1024U - 64U * 1024U)
+#define CONTENT_RANGE_DEFAULT_LIMIT 4096
+#define CONTENT_RANGE_MAX_LIMIT 65536
#define HISTORY_CURSOR_BYTES 47U
#define HISTORY_CURSOR_TEXT_MAX 64U
#define SESSION_CURSOR_BYTES_MAX (1U + 8U + 4U + TINY_SESSION_ID_MAX + 4U)
@@ -26,6 +28,120 @@ typedef struct history_cursor
char message_id[27];
} history_cursor_t;
+static bool
+utf8_boundary(const char *content, size_t length, size_t offset)
+{
+ return offset == 0 || offset == length || ((unsigned char)content[offset] & 0xc0U) != 0x80U;
+}
+
+static void
+call_content_view(tiny_mcp_tools_context_t *context, const mcpc_tool_t *tool, mcpc_ucbr_t *result)
+{
+ char *message_id = tiny_mcp_tool_get_string(tool, "message_id", TINY_MESSAGE_ID_LENGTH);
+ int32_t requested_offset = 0, requested_limit = CONTENT_RANGE_DEFAULT_LIMIT;
+ int32_t expected_revision = 0;
+ mcpc_errcode_t offset_status
+ = mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"content_offset", &requested_offset);
+ mcpc_errcode_t limit_status
+ = mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"content_limit", &requested_limit);
+ mcpc_errcode_t revision_status
+ = mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"expected_revision", &expected_revision);
+ const tiny_store_entry_t *entry = NULL;
+ size_t entry_index = 0, content_length = 0, offset = 0, end = 0, chunk_length = 0;
+ char *chunk = NULL;
+ tiny_json_buffer_t json;
+ bool valid = context && message_id && tiny_message_id_valid(message_id)
+ && (offset_status == MCPC_EC_0 || offset_status == MCPC_EC_ANYDATA_NOTFOUND)
+ && requested_offset >= 0
+ && (limit_status == MCPC_EC_0 || limit_status == MCPC_EC_ANYDATA_NOTFOUND)
+ && requested_limit >= 1 && requested_limit <= CONTENT_RANGE_MAX_LIMIT
+ && (revision_status == MCPC_EC_0 || revision_status == MCPC_EC_ANYDATA_NOTFOUND)
+ && (revision_status != MCPC_EC_0 || expected_revision >= 1);
+ if (!valid)
+ {
+ tiny_mcp_tool_add_error(result,
+ "VALIDATION_ERROR",
+ "message_id, content_offset, content_limit or expected_revision is "
+ "invalid");
+ }
+ else if (!tiny_mcp_tools_ensure_memory_index(context))
+ {
+ tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "message index allocation failed");
+ }
+ else if (!tiny_memory_index_find(context->memory_index, message_id, &entry_index)
+ || !(entry = tiny_store_entry(context->store, entry_index)) || entry->deleted)
+ {
+ tiny_mcp_tool_add_error(result, "NOT_FOUND", "message was not found");
+ }
+ else if (revision_status == MCPC_EC_0 && entry->message.revision != (uint64_t)expected_revision)
+ {
+ tiny_mcp_tool_add_error(result, "CONFLICT", "message revision does not match");
+ }
+ else
+ {
+ content_length = strlen(entry->message.content);
+ offset = (size_t)requested_offset;
+ if (offset > content_length || !utf8_boundary(entry->message.content, content_length, offset))
+ {
+ tiny_mcp_tool_add_error(result,
+ "VALIDATION_ERROR",
+ "content_offset is outside content or not a UTF-8 boundary");
+ }
+ else
+ {
+ end = offset + (size_t)requested_limit;
+ if (end > content_length)
+ {
+ end = content_length;
+ }
+ while (end > offset && !utf8_boundary(entry->message.content, content_length, end))
+ {
+ --end;
+ }
+ if (end == offset && end < content_length)
+ {
+ do
+ {
+ ++end;
+ }
+ while (end < content_length
+ && !utf8_boundary(entry->message.content, content_length, end));
+ }
+ chunk_length = end - offset;
+ chunk = malloc(chunk_length + 1);
+ if (!chunk)
+ {
+ tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "out of memory");
+ }
+ else
+ {
+ memcpy(chunk, entry->message.content + offset, chunk_length);
+ chunk[chunk_length] = '\0';
+ tiny_json_init(&json);
+ tiny_json_raw(&json, "{\"content_range\":{\"message_id\":");
+ tiny_json_string(&json, entry->message.message_id);
+ tiny_json_raw(&json, ",\"revision\":");
+ tiny_json_u64(&json, entry->message.revision);
+ tiny_json_raw(&json, ",\"total_bytes\":");
+ tiny_json_u64(&json, content_length);
+ tiny_json_raw(&json, ",\"content_offset\":");
+ tiny_json_u64(&json, offset);
+ tiny_json_raw(&json, ",\"next_content_offset\":");
+ tiny_json_u64(&json, end);
+ tiny_json_raw(&json, ",\"complete\":");
+ tiny_json_raw(&json, end == content_length ? "true" : "false");
+ tiny_json_raw(&json, ",\"content\":");
+ tiny_json_string(&json, chunk);
+ tiny_json_raw(&json, "}}");
+ tiny_mcp_tool_add_json(result, &json);
+ tiny_json_free(&json);
+ }
+ }
+ }
+ free(chunk);
+ free(message_id);
+}
+
static uint64_t
session_hash(const char *value)
{
@@ -800,6 +916,14 @@ call_read_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result)
const tiny_message_t *last = NULL;
tiny_json_buffer_t json;
+ if (view && strcmp(view, "content") == 0)
+ {
+ call_content_view(context, tool, result);
+ free(view);
+ free(session);
+ free(cursor_text);
+ return;
+ }
if (view && strcmp(view, "context") == 0)
{
call_context_view(context, tool, result);
@@ -950,14 +1074,38 @@ 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, or sessions.", MCPC_U8STR, false)
+ tool,
+ "view",
+ "Read view: history (default), content, context, or sessions.",
+ MCPC_U8STR,
+ false)
&& tiny_mcp_tool_add_property(tool,
"session_id",
"Conversation session whose chronological history to read.",
MCPC_U8STR,
false)
+ && tiny_mcp_tool_add_property(tool,
+ "message_id",
+ "Message ULID required by the content and context views.",
+ MCPC_U8STR,
+ false)
+ && tiny_mcp_tool_add_property(tool,
+ "content_offset",
+ "UTF-8 byte offset for the content view; defaults to 0.",
+ MCPC_I32,
+ false)
&& tiny_mcp_tool_add_property(
- tool, "message_id", "Message ULID required by the context view.", MCPC_U8STR, false)
+ tool,
+ "content_limit",
+ "Target content chunk bytes from 1 to 65536; defaults to 4096.",
+ MCPC_I32,
+ false)
+ && tiny_mcp_tool_add_property(
+ tool,
+ "expected_revision",
+ "Optional positive revision for a consistent multi-call content read.",
+ MCPC_I32,
+ false)
&& tiny_mcp_tool_add_property(
tool, "before", "Context messages before the target, from 0 to 10.", MCPC_I32, false)
&& tiny_mcp_tool_add_property(
diff --git a/tests/function-coverage.md b/tests/function-coverage.md
index 8976580..9f5808d 100644
--- a/tests/function-coverage.md
+++ b/tests/function-coverage.md
@@ -15,5 +15,5 @@ byte/encoding logic is exposed through internal headers and tested directly.
| `domain/search.c` | `decode_codepoints`, `gram_hash`, `find_bucket`, `add_gram`, `index_text`, all `tiny_search_*` | `domain`: ASCII case folding, Unicode grams/tags, candidates, scoring and ownership |
| `storage/store.c` | error/I/O helpers, ULID, entry growth/search, record codec, replay/snapshot/recovery, all `tiny_store_*` | `storage`, `lifecycle`: lock, append/reopen, corruption, rebuild rollback, Unicode path |
| `mcp/json.c` | `reserve`, all `tiny_json_*` | `boundaries`: empty append, growth, control escaping, signed/unsigned integer limits, OOM |
-| `mcp/server.c` | line/JSON/parser/result/search helpers, `handle_call`, `tiny_convmem_server_run` | `smoke`, `lifecycle`: initialize, list/call, malformed/EOF behavior, persistence |
+| `mcp/server.c`, `mcp/tools/read_messages.c` | line/JSON/parser/result/search helpers, range read, `handle_call`, `tiny_convmem_server_run` | `smoke`, `lifecycle`, `protocol`: initialize, list/call, malformed/EOF behavior, persistence, UTF-8 range boundaries, revision conflict, read-only I/O |
| `app/main.c` | `usage`, `run`, `main` | `requires-data-dir`, `smoke`: missing CLI argument, normal startup and EOF |
diff --git a/tests/mcp_protocol_test.py b/tests/mcp_protocol_test.py
index c206515..3efbca0 100644
--- a/tests/mcp_protocol_test.py
+++ b/tests/mcp_protocol_test.py
@@ -34,7 +34,11 @@ class Server:
def send(self, message: Mapping[str, Any] | str) -> dict[str, Any]:
assert self.process.stdin and self.process.stdout
- line = message if isinstance(message, str) else json.dumps(message, separators=(",", ":"))
+ line = (
+ message
+ if isinstance(message, str)
+ else json.dumps(message, separators=(",", ":"), ensure_ascii=False)
+ )
self.process.stdin.write(line + "\n")
self.process.stdin.flush()
response_line = self.process.stdout.readline()
@@ -81,6 +85,7 @@ def tool_call(server: Server, request_id: int, name: str, arguments: Mapping[str
def tool_payload(response: Mapping[str, Any], expect_error: bool = False) -> dict[str, Any]:
+ assert "result" in response, response
result = response["result"]
assert bool(result.get("isError", False)) is expect_error, response
return json.loads(result["content"][0]["text"])
@@ -157,9 +162,12 @@ 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, or sessions.",
+ "view": "Read view: history (default), content, context, or sessions.",
"session_id": "Conversation session whose chronological history to read.",
- "message_id": "Message ULID required by the context view.",
+ "message_id": "Message ULID required by the content and context views.",
+ "content_offset": "UTF-8 byte offset for the content view; defaults to 0.",
+ "content_limit": "Target content chunk bytes from 1 to 65536; defaults to 4096.",
+ "expected_revision": "Optional positive revision for a consistent multi-call content read.",
"before": "Context messages before the target, from 0 to 10.",
"after": "Context messages after the target, from 0 to 10.",
"relation_depth": "Relation traversal depth from 0 to 2; defaults to 1.",
@@ -195,6 +203,8 @@ def main() -> None:
"content",
]
assert tools["read_messages"]["inputSchema"].get("required", []) == []
+ assert tools["read_messages"]["inputSchema"]["properties"]["content_offset"]["type"] == "integer"
+ assert tools["read_messages"]["inputSchema"]["properties"]["content_limit"]["type"] == "integer"
assert tools["read_messages"]["inputSchema"]["properties"]["limit"]["type"] == "integer"
assert tools["read_messages"]["inputSchema"]["properties"]["cursor"]["type"] == "string"
assert tools["search_messages"]["inputSchema"]["required"] == ["query"]
@@ -205,6 +215,218 @@ def main() -> None:
next_id += 1
assert empty_health["session_count"] == 0
+ range_content = ("Aあ𐍈B\n" * 1000) + "range-end"
+ range_message = tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "write_message",
+ {"session_id": "range-read", "role": "assistant", "content": range_content},
+ )
+ )["message"]
+ next_id += 1
+ range_health_before = tool_payload(
+ tool_call(server, next_id, "get_system_health", {})
+ )["io"]
+ next_id += 1
+ single_codepoint = tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "read_messages",
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "content_offset": 1,
+ "content_limit": 1,
+ },
+ )
+ )["content_range"]
+ next_id += 1
+ assert single_codepoint["content"] == "あ"
+ assert single_codepoint["content_offset"] == 1
+ assert single_codepoint["next_content_offset"] == 4
+ assert single_codepoint["complete"] is False
+ reconstructed: list[str] = []
+ range_offset = 0
+ while True:
+ chunk = tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "read_messages",
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "content_offset": range_offset,
+ "content_limit": 4097,
+ "expected_revision": range_message["revision"],
+ },
+ )
+ )["content_range"]
+ next_id += 1
+ assert chunk["content_offset"] == range_offset
+ assert chunk["revision"] == range_message["revision"]
+ assert chunk["total_bytes"] == len(range_content.encode("utf-8"))
+ assert chunk["next_content_offset"] > range_offset
+ reconstructed.append(chunk["content"])
+ range_offset = chunk["next_content_offset"]
+ if chunk["complete"]:
+ break
+ assert "".join(reconstructed) == range_content
+ end_chunk = tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "read_messages",
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "content_offset": range_offset,
+ },
+ )
+ )["content_range"]
+ next_id += 1
+ assert end_chunk["content"] == ""
+ assert end_chunk["complete"] is True
+ maximum_chunk = tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "read_messages",
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "content_limit": 65536,
+ },
+ )
+ )["content_range"]
+ next_id += 1
+ assert maximum_chunk["content"] == range_content
+ assert maximum_chunk["complete"] is True
+ for arguments, expected_code in [
+ (
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "content_offset": 2,
+ },
+ "VALIDATION_ERROR",
+ ),
+ (
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "content_offset": len(range_content.encode("utf-8")) + 1,
+ },
+ "VALIDATION_ERROR",
+ ),
+ (
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "content_offset": -1,
+ },
+ "VALIDATION_ERROR",
+ ),
+ (
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "content_limit": 0,
+ },
+ "VALIDATION_ERROR",
+ ),
+ (
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "expected_revision": 0,
+ },
+ "VALIDATION_ERROR",
+ ),
+ (
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "content_limit": 65537,
+ },
+ "VALIDATION_ERROR",
+ ),
+ (
+ {
+ "view": "content",
+ "message_id": range_message["message_id"],
+ "expected_revision": range_message["revision"] + 1,
+ },
+ "CONFLICT",
+ ),
+ (
+ {
+ "view": "content",
+ "message_id": "01ARZ3NDEKTSV4RRFFQ69G5FAW",
+ },
+ "NOT_FOUND",
+ ),
+ ]:
+ error = tool_payload(
+ tool_call(server, next_id, "read_messages", arguments), True
+ )
+ next_id += 1
+ assert error["error"]["code"] == expected_code
+ range_health_after = tool_payload(
+ tool_call(server, next_id, "get_system_health", {})
+ )["io"]
+ next_id += 1
+ assert range_health_after == range_health_before
+ range_history = tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "read_messages",
+ {"view": "history", "session_id": "range-read"},
+ )
+ )["history"]
+ next_id += 1
+ persisted_range_message = next(
+ item for item in range_history if item["message_id"] == range_message["message_id"]
+ )
+ assert persisted_range_message["revision"] == range_message["revision"]
+ assert persisted_range_message["content"] == range_content
+
+ deleted_range_message = tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "write_message",
+ {"session_id": "range-read", "role": "user", "content": "delete me"},
+ )
+ )["message"]
+ next_id += 1
+ assert tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "delete_message",
+ {"message_id": deleted_range_message["message_id"]},
+ )
+ )["status"] == "OK"
+ next_id += 1
+ deleted_range_error = tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "read_messages",
+ {
+ "view": "content",
+ "message_id": deleted_range_message["message_id"],
+ },
+ ),
+ True,
+ )
+ next_id += 1
+ assert deleted_range_error["error"]["code"] == "NOT_FOUND"
+
message_ids: list[str] = []
for index in range(20):
payload = tool_payload(