From e881bc49984f910d5c83ab97b62ee4c513270e49 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=CE=A7=CE=B3=CF=86=CF=84=20Kompanion?=
 <chigammaphitau@chelydra.at>
Date: Wed, 15 Oct 2025 15:16:56 +1300
Subject: [PATCH] First prototype

---
 README.md                         |  11 +-
 docs/configuration.md             |  20 ++++
 docs/using-memory-tools.md        |  48 ++++++++
 src/dal/PgDal.cpp                 | 124 +++++++++++++++++++-
 src/dal/PgDal.hpp                 |  14 +++
 src/mcp/HandlersMemory.hpp        | 182 ++++++++++++++++++++++++++++++
 src/mcp/RegisterTools.hpp         |   2 +
 src/mcp/ToolSchemas.json          |   9 ++
 tests/CMakeLists.txt              |   8 ++
 tests/contract/test_mcp_tools.cpp |  22 +++-
 10 files changed, 432 insertions(+), 8 deletions(-)
 create mode 100644 docs/configuration.md
 create mode 100644 docs/using-memory-tools.md

diff --git a/README.md b/README.md
index 0107a4e..8e9dc83 100644
--- a/README.md
+++ b/README.md
@@ -17,4 +17,13 @@ cmake --build build -j
 ## Next
 - Add qtmcp dependency and implement server with tool registration.
 - Implement adapters: embedder(s) + vector store(s).
-- Wire `save_context`/`recall_context` to persistent store.
+- Flesh out Postgres DAL paths (prepared statements + pgvector wiring).
+
+## Memory Tools
+- `kom.memory.v1.save_context` persists conversational or workspace state in a namespace.
+- `kom.memory.v1.recall_context` retrieves stored context by key, tags, or time window.
+- See `docs/using-memory-tools.md` for integration notes (Codey, Claude Code) and request samples.
+
+## Integrations
+- **Kompanion-Konsole** — demo plugin for KDE Konsole that lets agents hand terminals over to the Kompanion runtime. See `integrations/konsole/README.md`.
+- **JavaScript helpers** — Node.js utilities that call the MCP memory tools from scripts or web extensions. See `integrations/js/`.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..7ac4f0f
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,20 @@
+# Kompanion Configuration
+
+Kompanion adheres to KDE’s KConfig conventions so deployments are kioskable and compatible with other desktop tooling.
+
+## Configuration File
+- Location: `${XDG_CONFIG_HOME:-~/.config}/kompanionrc`
+- Group: `[Database]`
+- Key: `PgDsn=postgresql://user:pass@host/dbname`
+
+The CLI (`kompanion`) and MCP runner (`kom_mcp`) fall back to this entry when the `PG_DSN` environment variable is not set. If neither are present the in-memory DAL stub is used.
+
+## Future HTTP Streaming
+While current tooling focuses on stdio dispatch (for editor and agent integration), the roadmap includes an HTTP/2 or WebSocket streaming surface so MCP clients can maintain persistent conversations without leaving CLI compatibility behind. The same configuration keys will apply for both transports.
+
+## Test Database
+Bootstrap a local Postgres instance using the provided scripts:
+```bash
+ROLE=kompanion PASS=komup db/scripts/create-test-db.sh kompanion_test
+```
+This loads the schemas from `db/init/` and prepares the DSN you can reference in `kompanionrc`.
diff --git a/docs/using-memory-tools.md b/docs/using-memory-tools.md
new file mode 100644
index 0000000..12b3001
--- /dev/null
+++ b/docs/using-memory-tools.md
@@ -0,0 +1,48 @@
+# Using Save/Recall Context Tools
+
+The Kompanion MCP backend exposes `kom.memory.v1.save_context` and `kom.memory.v1.recall_context` so editor-embedded agents can persist and retrieve working memory.
+
+## Registering the tools
+1. Ensure the MCP client loads the server manifest (see `src/mcp/ToolSchemas.json`).
+2. Add both tool names to the capability list you pass into the MCP handshake.
+3. When the client calls `register_default_tools`, they become available to dispatch via `KomMcpServer::dispatch`.
+
+## Client adapter hints
+- **Codey (Google)** – map Codey’s `tool_invocation` hooks to MCP calls. Persist summary/state blobs after each completion:
+  ```json
+  {
+    "tool": "kom.memory.v1.save_context",
+    "arguments": {
+      "namespace": "project:metal",
+      "key": "codey/session",
+      "content": {"summary": "Refactored PgDal for TTL support"},
+      "tags": ["codey", "memory"]
+    }
+  }
+  ```
+  On session start, call `kom.memory.v1.recall_context` with the namespace/key to warm the local context buffer.
+- **Claude Code (Anthropic)** – use the `tool_use` event to flush conversational checkpoints:
+  ```json
+  {
+    "tool": "kom.memory.v1.recall_context",
+    "arguments": {
+      "namespace": "thread:123",
+      "limit": 5,
+      "tags": ["task"]
+    }
+  }
+  ```
+  Feed the returned snippets back into Claude’s prompt so follow-up completions have grounding data.
+
+## Response fields
+- `save_context` returns `{ "id": string, "created_at": ISO8601 }`.
+- `recall_context` returns `{ "items": [{ "id", "key?", "content", "tags", "created_at" }] }`.
+
+## Testing locally
+```bash
+cmake -S . -B build
+cmake --build build
+ctest --test-dir build --output-on-failure
+```
+
+These commands build `kom_mcp` plus the test harness that exercises the new context tools.
diff --git a/src/dal/PgDal.cpp b/src/dal/PgDal.cpp
index e7a05a9..223803b 100644
--- a/src/dal/PgDal.cpp
+++ b/src/dal/PgDal.cpp
@@ -2,9 +2,13 @@
 
 #include <algorithm>
 #include <cctype>
+#include <chrono>
+#include <ctime>
+#include <iomanip>
 #include <numeric>
 #include <sstream>
 #include <stdexcept>
+#include <unordered_set>
 
 #ifdef HAVE_PG
 #include <pqxx/array>
@@ -152,6 +156,7 @@ std::string PgDal::upsertItem(const ItemRow& row) {
     return pgUpsertItem(row).first;
   }
 #endif
+  const auto now = std::chrono::system_clock::now();
   ItemRow stored = row;
   if (stored.id.empty()) {
     stored.id = allocateId(nextItemId_, "item_");
@@ -160,6 +165,21 @@ std::string PgDal::upsertItem(const ItemRow& row) {
   auto existing = items_.find(stored.id);
   if (existing != items_.end()) {
     stored.revision = existing->second.revision + 1;
+    if (stored.created_at == std::chrono::system_clock::time_point{}) {
+      stored.created_at = existing->second.created_at;
+    }
+    if (!stored.expires_at && existing->second.expires_at) {
+      stored.expires_at = existing->second.expires_at;
+    }
+    if (stored.metadata_json.empty()) {
+      stored.metadata_json = existing->second.metadata_json;
+    }
+  }
+  if (stored.created_at == std::chrono::system_clock::time_point{}) {
+    stored.created_at = now;
+  }
+  if (!stored.metadata_json.empty()) {
+    // keep as provided
   }
 
   items_[stored.id] = stored;
@@ -240,10 +260,12 @@ std::vector<ItemRow> PgDal::searchText(const std::string& namespaceId,
   if (bucketIt == itemsByNamespace_.end()) return results;
 
   const std::string loweredQuery = toLower(query);
+  const auto now = std::chrono::system_clock::now();
 
   for (const auto& itemId : bucketIt->second) {
     auto itemIt = items_.find(itemId);
     if (itemIt == items_.end()) continue;
+    if (isExpired(itemIt->second, now)) continue;
 
     if (!loweredQuery.empty()) {
       const std::string loweredText = toLower(itemIt->second.text.value_or(std::string()));
@@ -273,10 +295,14 @@ std::vector<std::pair<std::string, float>> PgDal::searchVector(
 
   auto bucketIt = itemsByNamespace_.find(namespaceId);
   if (bucketIt == itemsByNamespace_.end()) return scores;
+  const auto now = std::chrono::system_clock::now();
 
   for (const auto& itemId : bucketIt->second) {
     auto chunkBucketIt = chunksByItem_.find(itemId);
     if (chunkBucketIt == chunksByItem_.end()) continue;
+    auto itemIt = items_.find(itemId);
+    if (itemIt == items_.end()) continue;
+    if (isExpired(itemIt->second, now)) continue;
 
     float bestScore = -1.0f;
     for (const auto& chunkId : chunkBucketIt->second) {
@@ -323,6 +349,61 @@ std::optional<ItemRow> PgDal::getItemById(const std::string& id) const {
   return it->second;
 }
 
+std::vector<ItemRow> PgDal::fetchContext(const std::string& namespaceId,
+                                         const std::optional<std::string>& key,
+                                         const std::vector<std::string>& tags,
+                                         const std::optional<std::string>& sinceIso,
+                                         int limit) const {
+#ifdef HAVE_PG
+  if (connected_ && !useInMemory_) {
+    // TODO: implement direct Postgres query once DAL wiring is complete.
+    return {};
+  }
+#endif
+  std::vector<ItemRow> results;
+  if (!connected_ || limit == 0) {
+    return results;
+  }
+
+  auto bucketIt = itemsByNamespace_.find(namespaceId);
+  if (bucketIt == itemsByNamespace_.end()) {
+    return results;
+  }
+
+  const auto now = std::chrono::system_clock::now();
+  const bool filterSince = sinceIso.has_value() && !sinceIso->empty();
+
+  for (auto it = bucketIt->second.rbegin(); it != bucketIt->second.rend(); ++it) {
+    auto itemIt = items_.find(*it);
+    if (itemIt == items_.end()) {
+      continue;
+    }
+    const ItemRow& row = itemIt->second;
+    if (isExpired(row, now)) {
+      continue;
+    }
+    if (key && row.key != key) {
+      continue;
+    }
+    if (!tags.empty() && !includesAllTags(row.tags, tags)) {
+      continue;
+    }
+    if (filterSince) {
+      const std::string createdIso = toIso8601(row.created_at);
+      if (!createdIso.empty() && createdIso < sinceIso.value()) {
+        continue;
+      }
+    }
+
+    results.push_back(row);
+    if (limit > 0 && static_cast<int>(results.size()) >= limit) {
+      break;
+    }
+  }
+
+  return results;
+}
+
 std::pair<std::string, int> PgDal::upsertItem(
     const std::string& namespace_id,
     const std::optional<std::string>& key,
@@ -332,7 +413,8 @@ std::pair<std::string, int> PgDal::upsertItem(
   ItemRow row;
   row.namespace_id = namespace_id;
   row.key = key;
-  row.content_json = metadata_json.empty() ? content : metadata_json;
+  row.content_json = content;
+  row.metadata_json = metadata_json;
   if (!content.empty()) {
     row.text = content;
   }
@@ -432,6 +514,44 @@ std::string PgDal::toLower(const std::string& value) {
   return lowered;
 }
 
+std::string PgDal::toIso8601(std::chrono::system_clock::time_point tp) {
+  if (tp == std::chrono::system_clock::time_point{}) {
+    return std::string();
+  }
+  auto tt = std::chrono::system_clock::to_time_t(tp);
+  std::tm tm{};
+#if defined(_WIN32)
+  gmtime_s(&tm, &tt);
+#else
+  gmtime_r(&tt, &tm);
+#endif
+  std::ostringstream os;
+  os << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ");
+  return os.str();
+}
+
+bool PgDal::includesAllTags(const std::vector<std::string>& haystack,
+                            const std::vector<std::string>& needles) {
+  if (needles.empty()) {
+    return true;
+  }
+  std::unordered_set<std::string> lookup(haystack.begin(), haystack.end());
+  for (const auto& tag : needles) {
+    if (!lookup.count(tag)) {
+      return false;
+    }
+  }
+  return true;
+}
+
+bool PgDal::isExpired(const ItemRow& row,
+                      std::chrono::system_clock::time_point now) const {
+  if (!row.expires_at.has_value()) {
+    return false;
+  }
+  return now >= row.expires_at.value();
+}
+
 std::string PgDal::escapePgArrayElement(const std::string& value) {
   std::string escaped;
   escaped.reserve(value.size());
@@ -597,6 +717,8 @@ ItemRow PgDal::mapItemRow(const pqxx::row& row) const {
   }
   item.tags = parseTextArrayField(row[5]);
   item.revision = row[6].as<int>(1);
+  item.metadata_json = "{}";
+  item.created_at = std::chrono::system_clock::now();
   return item;
 }
 
diff --git a/src/dal/PgDal.hpp b/src/dal/PgDal.hpp
index b7afae8..28f731c 100644
--- a/src/dal/PgDal.hpp
+++ b/src/dal/PgDal.hpp
@@ -2,6 +2,7 @@
 
 #include "IDatabase.hpp"
 
+#include <chrono>
 #include <memory>
 #include <optional>
 #include <string>
@@ -27,8 +28,11 @@ struct ItemRow {
   std::optional<std::string> key;
   std::string content_json;
   std::optional<std::string> text;
+  std::string metadata_json;
   std::vector<std::string> tags;
   int revision = 1;
+  std::chrono::system_clock::time_point created_at{};
+  std::optional<std::chrono::system_clock::time_point> expires_at;
 };
 
 struct ChunkRow {
@@ -71,6 +75,11 @@ public:
       const std::vector<float>& embedding,
       int limit);
   std::optional<ItemRow> getItemById(const std::string& id) const;
+  std::vector<ItemRow> fetchContext(const std::string& namespaceId,
+                                    const std::optional<std::string>& key,
+                                    const std::vector<std::string>& tags,
+                                    const std::optional<std::string>& sinceIso,
+                                    int limit) const;
 
   // IDatabase overrides (stubbed for now to operate on the in-memory backing store).
   std::pair<std::string, int> upsertItem(
@@ -92,6 +101,11 @@ public:
 private:
   std::string allocateId(std::size_t& counter, const std::string& prefix);
   static std::string toLower(const std::string& value);
+  static std::string toIso8601(std::chrono::system_clock::time_point tp);
+  static bool includesAllTags(const std::vector<std::string>& haystack,
+                              const std::vector<std::string>& needles);
+  bool isExpired(const ItemRow& row,
+                 std::chrono::system_clock::time_point now) const;
   void resetInMemoryStore();
   static std::string toPgArrayLiteral(const std::vector<std::string>& values);
   static std::string escapePgArrayElement(const std::string& value);
diff --git a/src/mcp/HandlersMemory.hpp b/src/mcp/HandlersMemory.hpp
index 02e88bb..a56fa12 100644
--- a/src/mcp/HandlersMemory.hpp
+++ b/src/mcp/HandlersMemory.hpp
@@ -1,8 +1,10 @@
 #pragma once
 
 #include <algorithm>
+#include <chrono>
 #include <cctype>
 #include <cstdlib>
+#include <ctime>
 #include <iomanip>
 #include <optional>
 #include <sstream>
@@ -44,6 +46,15 @@ inline std::string json_escape(const std::string& in) {
     return os.str();
 }
 
+inline std::string trim(const std::string& in) {
+    std::size_t start = 0;
+    while (start < in.size() && std::isspace(static_cast<unsigned char>(in[start]))) ++start;
+    if (start == in.size()) return std::string();
+    std::size_t end = in.size();
+    while (end > start && std::isspace(static_cast<unsigned char>(in[end - 1]))) --end;
+    return in.substr(start, end - start);
+}
+
 inline std::string error_response(const std::string& code, const std::string& message) {
     std::ostringstream os;
     os << "{\"error\":{\"code\":\"" << json_escape(code)
@@ -137,6 +148,37 @@ inline std::string extract_string_field(const std::string& json, const std::stri
     return os.str();
 }
 
+inline std::optional<std::string> extract_json_value(const std::string& json, const std::string& key) {
+    const std::string pattern = "\"" + key + "\"";
+    auto keyPos = json.find(pattern);
+    if (keyPos == std::string::npos) return std::nullopt;
+    auto colonPos = json.find(':', keyPos + pattern.size());
+    if (colonPos == std::string::npos) return std::nullopt;
+    ++colonPos;
+    while (colonPos < json.size() && std::isspace(static_cast<unsigned char>(json[colonPos]))) ++colonPos;
+    if (colonPos >= json.size()) return std::nullopt;
+
+    const char start = json[colonPos];
+    if (start == '{') {
+        return find_object_segment(json, key);
+    }
+    if (start == '[') {
+        return find_array_segment(json, key);
+    }
+    if (start == '"') {
+        auto decoded = extract_string_field(json, key);
+        std::ostringstream os;
+        os << '"' << json_escape(decoded) << '"';
+        return os.str();
+    }
+
+    std::size_t end = colonPos;
+    while (end < json.size() && json[end] != ',' && json[end] != '}' && json[end] != ']') ++end;
+    std::string token = trim(json.substr(colonPos, end - colonPos));
+    if (token.empty()) return std::nullopt;
+    return token;
+}
+
 inline std::optional<int> extract_int_field(const std::string& json, const std::string& key) {
     const std::string pattern = "\"" + key + "\"";
     auto pos = json.find(pattern);
@@ -155,6 +197,22 @@ inline std::optional<int> extract_int_field(const std::string& json, const std::
     }
 }
 
+inline std::string iso8601_from_tp(std::chrono::system_clock::time_point tp) {
+    if (tp == std::chrono::system_clock::time_point{}) {
+        return std::string();
+    }
+    auto tt = std::chrono::system_clock::to_time_t(tp);
+    std::tm tm{};
+#if defined(_WIN32)
+    gmtime_s(&tm, &tt);
+#else
+    gmtime_r(&tt, &tm);
+#endif
+    std::ostringstream os;
+    os << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ");
+    return os.str();
+}
+
 inline std::vector<std::string> parse_object_array(const std::string& json, const std::string& key) {
     std::vector<std::string> objects;
     auto segment = find_array_segment(json, key);
@@ -431,4 +489,128 @@ inline std::string search_memory(const std::string& reqJson) {
     return detail::serialize_matches(matches);
 }
 
+inline std::string save_context(const std::string& reqJson) {
+    const std::string nsName = detail::extract_string_field(reqJson, "namespace");
+    if (nsName.empty()) {
+        return detail::error_response("bad_request", "namespace is required");
+    }
+
+    auto contentToken = detail::extract_json_value(reqJson, "content");
+    if (!contentToken) {
+        return detail::error_response("bad_request", "content is required");
+    }
+
+    auto nsRow = detail::database().ensureNamespace(nsName);
+    if (!nsRow) {
+        return detail::error_response("internal_error", "failed to ensure namespace");
+    }
+
+    std::string key = detail::extract_string_field(reqJson, "key");
+    auto tags = detail::parse_string_array(reqJson, "tags");
+    auto ttlOpt = detail::extract_int_field(reqJson, "ttl_seconds");
+
+    kom::PgDal& dal = detail::database();
+    kom::ItemRow row;
+    row.namespace_id = nsRow->id;
+    if (!key.empty()) {
+        row.key = key;
+    }
+    row.tags = std::move(tags);
+    row.content_json = detail::trim(*contentToken);
+    row.metadata_json = "{}";
+    row.created_at = std::chrono::system_clock::now();
+    if (ttlOpt && *ttlOpt > 0) {
+        row.expires_at = row.created_at + std::chrono::seconds(*ttlOpt);
+    }
+
+    if (!contentToken->empty() && (*contentToken)[0] == '"') {
+        auto textValue = detail::extract_string_field(reqJson, "content");
+        if (!textValue.empty()) {
+            row.text = textValue;
+        }
+    }
+
+    try {
+        const std::string id = dal.upsertItem(row);
+        auto stored = dal.getItemById(id);
+        const auto createdTp = stored ? stored->created_at : row.created_at;
+        std::string createdIso = detail::iso8601_from_tp(createdTp);
+        if (createdIso.empty()) {
+            createdIso = detail::iso8601_from_tp(std::chrono::system_clock::now());
+        }
+
+        std::ostringstream os;
+        os << "{\"id\":\"" << detail::json_escape(id) << "\"";
+        os << ",\"created_at\":\"" << createdIso << "\"";
+        os << "}";
+        return os.str();
+    } catch (const std::exception& ex) {
+        return detail::error_response("internal_error", ex.what());
+    }
+}
+
+inline std::string recall_context(const std::string& reqJson) {
+    const std::string nsName = detail::extract_string_field(reqJson, "namespace");
+    if (nsName.empty()) {
+        return detail::error_response("bad_request", "namespace is required");
+    }
+
+    std::string key = detail::extract_string_field(reqJson, "key");
+    auto tags = detail::parse_string_array(reqJson, "tags");
+    auto limitOpt = detail::extract_int_field(reqJson, "limit");
+    std::string since = detail::extract_string_field(reqJson, "since");
+
+    int limit = limitOpt.value_or(10);
+    if (limit <= 0) {
+        limit = 10;
+    }
+
+    auto nsRow = detail::database().findNamespace(nsName);
+    if (!nsRow) {
+        return std::string("{\"items\":[]}");
+    }
+
+    std::optional<std::string> keyOpt;
+    if (!key.empty()) {
+        keyOpt = key;
+    }
+    std::optional<std::string> sinceOpt;
+    if (!since.empty()) {
+        sinceOpt = since;
+    }
+
+    kom::PgDal& dal = detail::database();
+    auto rows = dal.fetchContext(nsRow->id, keyOpt, tags, sinceOpt, limit);
+
+    std::ostringstream os;
+    os << "{\"items\":[";
+    for (std::size_t i = 0; i < rows.size(); ++i) {
+        const auto& row = rows[i];
+        if (i) os << ',';
+        os << "{\"id\":\"" << detail::json_escape(row.id) << "\"";
+        if (row.key) {
+            os << ",\"key\":\"" << detail::json_escape(*row.key) << "\"";
+        }
+        os << ",\"content\":";
+        if (!row.content_json.empty()) {
+            os << row.content_json;
+        } else {
+            os << "null";
+        }
+        os << ",\"tags\":[";
+        for (std::size_t t = 0; t < row.tags.size(); ++t) {
+            if (t) os << ',';
+            os << "\"" << detail::json_escape(row.tags[t]) << "\"";
+        }
+        os << "]";
+        const std::string createdIso = detail::iso8601_from_tp(row.created_at);
+        if (!createdIso.empty()) {
+            os << ",\"created_at\":\"" << createdIso << "\"";
+        }
+        os << "}";
+    }
+    os << "]}";
+    return os.str();
+}
+
 } // namespace Handlers
diff --git a/src/mcp/RegisterTools.hpp b/src/mcp/RegisterTools.hpp
index 2251099..9f6a57a 100644
--- a/src/mcp/RegisterTools.hpp
+++ b/src/mcp/RegisterTools.hpp
@@ -5,6 +5,8 @@
 #include "HandlersMemory.hpp"
 
 inline void register_default_tools(KomMcpServer& server) {
+    server.registerTool("kom.memory.v1.save_context", Handlers::save_context);
+    server.registerTool("kom.memory.v1.recall_context", Handlers::recall_context);
     server.registerTool("kom.memory.v1.upsert_memory", Handlers::upsert_memory);
     server.registerTool("kom.memory.v1.search_memory", Handlers::search_memory);
     server.registerTool("kom.local.v1.backup.export_encrypted", Handlers::backup_export_encrypted);
diff --git a/src/mcp/ToolSchemas.json b/src/mcp/ToolSchemas.json
index 00dc98f..1548039 100644
--- a/src/mcp/ToolSchemas.json
+++ b/src/mcp/ToolSchemas.json
@@ -2,6 +2,7 @@
   "namespace": "kom.memory.v1",
   "tools": {
     "save_context": {
+      "description": "Persist context payload in the namespace-backed memory store.",
       "input": {
         "type": "object",
         "properties": {
@@ -23,6 +24,7 @@
       }
     },
     "recall_context": {
+      "description": "Recall stored context entries filtered by key, tags, and time window.",
       "input": {
         "type": "object",
         "properties": {
@@ -56,6 +58,7 @@
       }
     },
     "embed_text": {
+      "description": "Return embedding vectors for provided text inputs.",
       "input": {
         "type": "object",
         "properties": {
@@ -74,6 +77,7 @@
       }
     },
     "upsert_memory": {
+      "description": "Upsert semantic memory items with optional precomputed embeddings.",
       "input": {
         "type": "object",
         "properties": {
@@ -103,6 +107,7 @@
       }
     },
     "search_memory": {
+      "description": "Hybrid semantic search across stored memory chunks.",
       "input": {
         "type": "object",
         "properties": {
@@ -140,6 +145,7 @@
       }
     },
     "warm_cache": {
+      "description": "Queue embedding warm-up jobs for recent namespace items.",
       "input": {
         "type": "object",
         "properties": {
@@ -158,6 +164,7 @@
     },
 
     "kom.local.v1.backup.export_encrypted": {
+      "description": "Queue an encrypted backup export for the requested namespaces.",
       "input": {
         "type": "object",
         "properties": {
@@ -177,6 +184,7 @@
     },
 
     "kom.local.v1.backup.import_encrypted": {
+      "description": "Import an encrypted backup artifact back into the local store.",
       "input": {
         "type": "object",
         "properties": {
@@ -195,6 +203,7 @@
     ,
 
     "kom.meta.v1.project_snapshot": {
+      "description": "Produce a high-level project status snapshot for downstream MCP clients.",
       "input": {
         "type": "object",
         "properties": {
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index 1320449..002c5aa 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -13,3 +13,11 @@ target_include_directories(contract_memory PRIVATE ${PROJECT_SOURCE_DIR}/src)
 target_link_libraries(contract_memory PRIVATE kom_dal)
 
 add_test(NAME contract_memory COMMAND contract_memory)
+
+add_executable(test_memory_exchange
+  mcp/test_memory_exchange.cpp
+)
+target_include_directories(test_memory_exchange PRIVATE ${PROJECT_SOURCE_DIR}/src)
+target_link_libraries(test_memory_exchange PRIVATE kom_dal)
+
+add_test(NAME mcp_memory_exchange COMMAND test_memory_exchange)
diff --git a/tests/contract/test_mcp_tools.cpp b/tests/contract/test_mcp_tools.cpp
index d69e412..3af9bcd 100644
--- a/tests/contract/test_mcp_tools.cpp
+++ b/tests/contract/test_mcp_tools.cpp
@@ -20,13 +20,23 @@ int main() {
     KomMcpServer server;
     register_default_tools(server);
 
-    const std::string upsertReq = R"({"namespace":"tests","items":[{"text":"hello world","tags":["greeting"],"embedding":[0.1,0.2,0.3]},{"text":"hola mundo","embedding":[0.05,0.1,0.15]}]})";
-    std::string upsertResp = server.dispatch("kom.memory.v1.upsert_memory", upsertReq);
-    if (!expect_contains(upsertResp, "\"upserted\":2", "upsert_memory count")) return 1;
-    if (!expect_contains(upsertResp, "\"status\":\"ok\"", "upsert_memory status")) return 1;
+const std::string upsertReq = R"({"namespace":"tests","items":[{"text":"hello world","tags":["greeting"],"embedding":[0.1,0.2,0.3]},{"text":"hola mundo","embedding":[0.05,0.1,0.15]}]})";
+std::string upsertResp = server.dispatch("kom.memory.v1.upsert_memory", upsertReq);
+if (!expect_contains(upsertResp, "\"upserted\":2", "upsert_memory count")) return 1;
+if (!expect_contains(upsertResp, "\"status\":\"ok\"", "upsert_memory status")) return 1;
 
-    std::string firstId;
-    const std::string idsAnchor = "\"ids\":[\"";
+const std::string saveReq = R"({"namespace":"tests","key":"greeting","content":{"text":"remember this","confidence":0.98},"tags":["context","demo"]})";
+std::string saveResp = server.dispatch("kom.memory.v1.save_context", saveReq);
+if (!expect_contains(saveResp, "\"id\"", "save_context id")) return 1;
+if (!expect_contains(saveResp, "\"created_at\"", "save_context timestamp")) return 1;
+
+const std::string recallReq = R"({"namespace":"tests","key":"greeting","limit":2})";
+std::string recallResp = server.dispatch("kom.memory.v1.recall_context", recallReq);
+if (!expect_contains(recallResp, "\"items\"", "recall_context items")) return 1;
+if (!expect_contains(recallResp, "\"confidence\":0.98", "recall_context returns payload")) return 1;
+
+std::string firstId;
+const std::string idsAnchor = "\"ids\":[\"";
     auto idsPos = upsertResp.find(idsAnchor);
     if (idsPos != std::string::npos) {
         idsPos += idsAnchor.size();
