summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/CMakeLists.txt20
-rw-r--r--tests/mcp_protocol_test.py236
-rw-r--r--tests/mcpc_test.c41
3 files changed, 297 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index ad314eb..fd9ac9e 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -22,6 +22,24 @@ if(MSVC)
endif()
add_test(NAME tiny-convmem-mcp.domain COMMAND tiny-convmem-domain-test)
+add_executable(tiny-convmem-mcpc-test mcpc_test.c)
+target_include_directories(tiny-convmem-mcpc-test PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
+target_compile_features(tiny-convmem-mcpc-test PRIVATE c_std_17)
+target_link_libraries(tiny-convmem-mcpc-test PRIVATE mcpc::mcpc)
+if(MSVC)
+ target_compile_options(tiny-convmem-mcpc-test PRIVATE /utf-8 /W4)
+endif()
+add_test(NAME tiny-convmem-mcp.mcpc COMMAND tiny-convmem-mcpc-test)
+
+find_package(Python3 REQUIRED COMPONENTS Interpreter)
+add_test(
+ NAME tiny-convmem-mcp.protocol
+ COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/mcp_protocol_test.py"
+ --server "$<TARGET_FILE:tiny-convmem-mcp>"
+ --iterations 1000
+)
+set_tests_properties(tiny-convmem-mcp.protocol PROPERTIES TIMEOUT 120)
+
add_executable(tiny-convmem-boundary-test
boundary_test.c
"${PROJECT_SOURCE_DIR}/src/mcp/json.c"
@@ -93,6 +111,8 @@ if(TINY_CONVMEM_ENABLE_ASAN AND MSVC)
TEST
tiny-convmem-mcp.smoke
tiny-convmem-mcp.domain
+ tiny-convmem-mcp.mcpc
+ tiny-convmem-mcp.protocol
tiny-convmem-mcp.boundaries
tiny-convmem-mcp.allocator
tiny-convmem-mcp.encoding
diff --git a/tests/mcp_protocol_test.py b/tests/mcp_protocol_test.py
new file mode 100644
index 0000000..118043f
--- /dev/null
+++ b/tests/mcp_protocol_test.py
@@ -0,0 +1,236 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import json
+import shutil
+import subprocess
+import tempfile
+import threading
+from collections.abc import Mapping
+from pathlib import Path
+from typing import Any
+
+
+class Server:
+ def __init__(self, executable: Path, data_dir: Path) -> None:
+ self.stderr: list[str] = []
+ self.process = subprocess.Popen(
+ [str(executable), "--data-dir", str(data_dir)],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ encoding="utf-8",
+ bufsize=1,
+ )
+ assert self.process.stdin and self.process.stdout and self.process.stderr
+ self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True)
+ self._stderr_thread.start()
+
+ def _drain_stderr(self) -> None:
+ assert self.process.stderr
+ self.stderr.extend(self.process.stderr)
+
+ 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=(",", ":"))
+ self.process.stdin.write(line + "\n")
+ self.process.stdin.flush()
+ response_line = self.process.stdout.readline()
+ if not response_line:
+ raise AssertionError(
+ f"server closed: returncode={self.process.poll()} stderr={''.join(self.stderr)[-4000:]}"
+ )
+ response = json.loads(response_line)
+ assert response.get("jsonrpc") == "2.0", response
+ return response
+
+ def notify(self, message: Mapping[str, Any]) -> None:
+ assert self.process.stdin
+ self.process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
+ self.process.stdin.flush()
+
+ def close(self) -> None:
+ if self.process.stdin:
+ self.process.stdin.close()
+ returncode = self.process.wait(timeout=15)
+ self._stderr_thread.join(timeout=2)
+ assert returncode == 0, f"returncode={returncode} stderr={''.join(self.stderr)}"
+ stderr = "".join(self.stderr)
+ assert "AddressSanitizer" not in stderr and "ERROR:" not in stderr, stderr
+
+
+def request(request_id: int, method: str, params: Mapping[str, Any] | None = None) -> dict[str, Any]:
+ message: dict[str, Any] = {"jsonrpc": "2.0", "id": request_id, "method": method}
+ if params is not None:
+ message["params"] = params
+ return message
+
+
+def tool_call(server: Server, request_id: int, name: str, arguments: Mapping[str, Any]) -> dict[str, Any]:
+ response = server.send(
+ request(request_id, "tools/call", {"name": name, "arguments": arguments})
+ )
+ assert response.get("id") == request_id, response
+ return response
+
+
+def tool_payload(response: Mapping[str, Any], expect_error: bool = False) -> dict[str, Any]:
+ result = response["result"]
+ assert bool(result.get("isError", False)) is expect_error, response
+ return json.loads(result["content"][0]["text"])
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--server", type=Path, required=True)
+ parser.add_argument("--iterations", type=int, default=1000)
+ args = parser.parse_args()
+ data_dir = Path(tempfile.mkdtemp(prefix="tiny-convmem-p0-"))
+ server = Server(args.server.resolve(), data_dir)
+ next_id = 1
+ try:
+ response = server.send(
+ request(
+ next_id,
+ "initialize",
+ {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {},
+ "clientInfo": {"name": "p0-regression", "version": "1"},
+ },
+ )
+ )
+ assert response["result"]["protocolVersion"] == "2024-11-05"
+ next_id += 1
+ server.notify({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
+
+ listed = server.send(request(next_id, "tools/list", {}))
+ assert listed["id"] == next_id # also proves the notification emitted no response
+ next_id += 1
+ tools = {item["name"]: item for item in listed["result"]["tools"]}
+ assert set(tools) == {
+ "write_message",
+ "read_messages",
+ "search_messages",
+ "delete_message",
+ "rebuild_index",
+ "get_system_health",
+ }
+ assert tools["write_message"]["inputSchema"]["required"] == [
+ "session_id",
+ "role",
+ "content",
+ ]
+ assert tools["read_messages"]["inputSchema"]["required"] == ["session_id"]
+ assert tools["search_messages"]["inputSchema"]["required"] == ["query"]
+ assert tools["search_messages"]["inputSchema"]["properties"]["limit"]["type"] == "integer"
+ assert tools["delete_message"]["inputSchema"]["required"] == ["message_id"]
+
+ message_ids: list[str] = []
+ for index in range(20):
+ payload = tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "write_message",
+ {"session_id": "target", "role": "user", "content": f"needle target {index}"},
+ )
+ )
+ next_id += 1
+ message_ids.append(payload["message"]["message_id"])
+ for index in range(10):
+ tool_payload(
+ tool_call(
+ server,
+ next_id,
+ "write_message",
+ {"session_id": "other", "role": "user", "content": f"needle other {index}"},
+ )
+ )
+ next_id += 1
+
+ history = tool_payload(
+ tool_call(server, next_id, "read_messages", {"session_id": "target"})
+ )["history"]
+ next_id += 1
+ assert len(history) == 20
+
+ for index in range(args.iterations):
+ arguments: dict[str, Any] = {"query": "needle"}
+ if index % 2:
+ arguments["session_id"] = "target"
+ results = tool_payload(
+ tool_call(server, next_id, "search_messages", arguments)
+ )["results"]
+ next_id += 1
+ assert len(results) == 20
+ if index % 2:
+ assert all(item["session_id"] == "target" for item in results)
+ if (index + 1) % 100 == 0:
+ print(f"completed {index + 1}/{args.iterations} alternating searches", flush=True)
+
+ assert tool_payload(
+ tool_call(server, next_id, "search_messages", {"query": "needle", "limit": 0}), True
+ )["status"] == "ERROR"
+ next_id += 1
+ assert tool_payload(
+ tool_call(server, next_id, "search_messages", {"query": "needle", "limit": 101}), True
+ )["status"] == "ERROR"
+ next_id += 1
+ fractional = tool_call(
+ server, next_id, "search_messages", {"query": "needle", "limit": 1.5}
+ )
+ assert fractional["error"]["code"] == -32602
+ next_id += 1
+ assert tool_payload(tool_call(server, next_id, "search_messages", {}), True)["status"] == "ERROR"
+ next_id += 1
+
+ assert tool_payload(
+ tool_call(server, next_id, "delete_message", {"message_id": message_ids[0]})
+ )["status"] == "OK"
+ next_id += 1
+ tool_payload(tool_call(server, next_id, "rebuild_index", {}))
+ next_id += 1
+ assert tool_payload(tool_call(server, next_id, "get_system_health", {}))["status"] == "OK"
+ next_id += 1
+
+ assert server.send("{bad}")["error"]["code"] == -32700
+ invalid = server.send({"id": next_id, "method": "tools/list"})
+ assert invalid["error"]["code"] == -32600
+ next_id += 1
+ missing_method = server.send({"jsonrpc": "2.0", "id": next_id})
+ assert missing_method["error"]["code"] == -32600
+ next_id += 1
+ null_id = server.send(
+ {"jsonrpc": "2.0", "id": None, "method": "tools/list", "params": {}}
+ )
+ assert null_id["error"]["code"] == -32600 and null_id["id"] is None
+ invalid_params = server.send(
+ {"jsonrpc": "2.0", "id": next_id, "method": "tools/list", "params": []}
+ )
+ assert invalid_params["error"]["code"] == -32602
+ next_id += 1
+ unknown_method = server.send(request(next_id, "unknown/method", {}))
+ assert unknown_method["error"]["code"] == -32601
+ next_id += 1
+ unknown_tool = tool_call(server, next_id, "unknown_tool", {})
+ assert unknown_tool["error"]["code"] == -32602
+ next_id += 1
+ missing_arguments = server.send(
+ request(next_id, "tools/call", {"name": "search_messages"})
+ )
+ assert missing_arguments["error"]["code"] == -32602
+ except Exception:
+ if server.process.poll() is None:
+ server.process.kill()
+ server.process.wait(timeout=5)
+ raise
+ else:
+ server.close()
+ finally:
+ shutil.rmtree(data_dir, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/mcpc_test.c b/tests/mcpc_test.c
new file mode 100644
index 0000000..b0d317e
--- /dev/null
+++ b/tests/mcpc_test.c
@@ -0,0 +1,41 @@
+#include <mcpc/mcpc.h>
+
+#include <stdio.h>
+
+#include "support/test.h"
+
+static void
+empty_callback(const mcpc_tool_t *tool, mcpc_ucbr_t *result)
+{
+ (void)tool;
+ mcpc_toolcall_result_add_text_printf8(result, (const char8_t *)"{}");
+}
+
+int
+main(void)
+{
+ tiny_test_state_t state = { "mcpc fork", 0, 0 };
+ FILE *input = tmpfile();
+ FILE *output = tmpfile();
+ mcpc_server_t *server;
+ mcpc_tool_t *tool;
+ mcpc_toolprop_t *property;
+ TINY_EXPECT(&state, input != NULL && output != NULL);
+ server = mcpc_server_new_iostrm(input, output);
+ TINY_EXPECT(&state, server != NULL);
+ tool = mcpc_tool_new2((const char8_t *)"test", (const char8_t *)"test tool");
+ TINY_EXPECT(&state, tool != NULL);
+ TINY_EXPECT(&state, mcpc_server_add_tool(server, tool) == MCPC_EC_BUG);
+ TINY_EXPECT(&state, mcpc_tool_set_call_cb(tool, empty_callback) == MCPC_EC_0);
+
+ property = mcpc_toolprop_new2((const char8_t *)"limit", (const char8_t *)"limit", MCPC_I32);
+ TINY_EXPECT(&state, property != NULL && mcpc_toolprop_is_required(property));
+ TINY_EXPECT(&state, mcpc_toolprop_set_required(property, false) == MCPC_EC_0);
+ TINY_EXPECT(&state, !mcpc_toolprop_is_required(property));
+ mcpc_tool_add_toolprop(tool, property);
+ TINY_EXPECT(&state, mcpc_server_add_tool(server, tool) == MCPC_EC_0);
+ TINY_EXPECT(&state, mcpc_server_close(server) == MCPC_EC_0);
+ fclose(input);
+ fclose(output);
+ return tiny_test_finish(&state);
+}