mcp-pocket-memory 0.5.0

MCP server for durable AI memory backed by SQLite
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
use std::{
    collections::BTreeSet,
    env,
    path::{Path, PathBuf},
    sync::Arc,
    time::Duration,
};

use ax_cache::Cache;
use rmcp::{
    ErrorData as McpError, ServerHandler,
    handler::server::{
        router::tool::ToolRouter,
        wrapper::{Json, Parameters},
    },
    model::{ServerCapabilities, ServerInfo},
    schemars, tool, tool_handler, tool_router,
};
use serde::{Deserialize, Serialize};

use crate::db::{Database, Memory};

const FIND_CACHE_CAPACITY_ENV: &str = "POCKET_MEMORY_FIND_CACHE_CAPACITY";
const FIND_CACHE_TTL_SECS_ENV: &str = "POCKET_MEMORY_FIND_CACHE_TTL_SECS";
const DEFAULT_FIND_CACHE_CAPACITY: usize = 512;
const DEFAULT_FIND_CACHE_TTL_SECS: u64 = 30;

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct UpsertMemoryRequest {
    #[schemars(
        description = "Stable kebab-case identifier. Reuse the exact same key to update existing memory. Examples: 'error-handling-pattern', 'db-pool-config', 'frontend-state-rule'"
    )]
    pub key: String,
    #[schemars(
        description = "Repository name this memory belongs to (e.g., 'mcp-pocket-memory'). REQUIRED for project-specific knowledge. Use null ONLY for universal/cross-repo knowledge."
    )]
    pub repo: Option<String>,
    #[schemars(
        description = "Optional topic tags for retrieval. Use 1-10 short lowercase tags. Standard tags such as architecture, api, auth, bug, convention, decision, dependency, deployment, performance, preference, requirement, security, testing, unicode, workaround are recommended, but custom tags like edge-case or long-text are allowed."
    )]
    pub tags: Option<Vec<String>>,
    #[schemars(
        description = "Concise but complete durable knowledge. Include WHAT, WHY, and WHEN. 1-3 sentences. Store only cross-session knowledge: decisions, conventions, bugs, constraints, preferences. DO NOT store current file contents or temporary analysis."
    )]
    pub content: String,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DeleteMemoryRequest {
    #[schemars(description = "Exact key of the memory to permanently delete.")]
    pub key: String,
}

#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct FindMemoryRequest {
    #[schemars(
        description = "Optional broad keyword text. Split into words and matched against memory key, content, repo, and tags. Use a few terms when you do not know the exact wording."
    )]
    pub keyword: Option<String>,
    #[schemars(
        description = "Repository name filter. ALWAYS provide this when working in a known repository. This is the highest-priority filter."
    )]
    pub repo: Option<String>,
    #[schemars(
        description = "Optional topic tags. Multiple tags use AND logic (all must match). Use to narrow context quickly. Custom tags are allowed."
    )]
    pub tags: Option<Vec<String>>,
    #[schemars(description = "Maximum results. Values clamped to 1-50. Default 10.")]
    pub limit: Option<usize>,
}

#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct UpsertMemoryResponse {
    pub memory: Memory,
}

#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct DeleteMemoryResponse {
    pub key: String,
    pub deleted: bool,
}

#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct FindMemoryResponse {
    pub memories: Vec<Memory>,
}

#[derive(Clone)]
pub struct PocketMemoryServer {
    db_path: PathBuf,
    find_cache: Arc<Cache<String, Vec<Memory>>>,
    find_cache_ttl: Duration,
    #[allow(dead_code)]
    tool_router: ToolRouter<Self>,
}

impl PocketMemoryServer {
    pub fn new(db_path: impl AsRef<Path>) -> Self {
        let cache_capacity = find_cache_capacity();
        Self {
            db_path: db_path.as_ref().to_owned(),
            find_cache: Arc::new(Cache::new(cache_capacity)),
            find_cache_ttl: find_cache_ttl(),
            tool_router: Self::tool_router(),
        }
    }

    fn db(&self) -> Result<Database, McpError> {
        Database::open(&self.db_path).map_err(map_db_error)
    }
}

#[tool_router]
impl PocketMemoryServer {
    #[tool(
        name = "upsert_memory",
        title = "Upsert Memory",
        description = "Store durable, cross-session knowledge. CALL THIS IMMEDIATELY when you learn:
- A project convention or rule (e.g., 'use anyhow for errors', 'naming convention for tables')
- An architecture decision and its rationale
- A recurring bug and its workaround
- A user preference or constraint
- A dependency version requirement or compatibility issue

DO NOT store: current file contents, temporary analysis, or information retrievable from code.

Parameters:
- key: Stable kebab-case identifier. Reuse exact key to update. Examples: 'error-handling-pattern', 'db-connection-pool-size', 'frontend-state-management'
- repo: REQUIRED if memory is project-specific. Use the repository name (e.g., 'mcp-pocket-memory'). Use null ONLY for universal knowledge.
- tags: Optional. Use 1-10 short tags when useful. Prefer consistent tags, but custom tags such as edge-case, security, unicode, and long-text are allowed.
- content: Concise but complete. Include WHAT the decision/rule is, WHY it exists, and WHEN it applies. 1-3 sentences.",
        annotations(
            title = "Upsert Memory",
            read_only_hint = false,
            destructive_hint = false,
            idempotent_hint = false,
            open_world_hint = false
        )
    )]
    fn upsert_memory(
        &self,
        Parameters(request): Parameters<UpsertMemoryRequest>,
    ) -> Result<Json<UpsertMemoryResponse>, McpError> {
        let key = clean_key(request.key)?;
        let repo = clean_optional(request.repo);
        let tags = clean_tags(request.tags)?;
        let content = clean_required("content", request.content)?;
        let memory = self
            .db()?
            .upsert_memory(&key, repo.as_deref(), &tags, &content)
            .map_err(map_db_error)?;
        self.find_cache.clear();

        Ok(Json(UpsertMemoryResponse { memory }))
    }

    #[tool(
        name = "delete_memory",
        title = "Delete Memory",
        description = "Permanently remove a memory by its exact key. Use ONLY when:
- The information is factually wrong and misleading
- The decision/convention has been explicitly revoked by the user
- The memory is obsolete (e.g., old bug fixed, old dependency removed)

DO NOT use this to correct a memory — use upsert_memory with the same key to update instead.",
        annotations(
            title = "Delete Memory",
            read_only_hint = false,
            destructive_hint = true,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    fn delete_memory(
        &self,
        Parameters(request): Parameters<DeleteMemoryRequest>,
    ) -> Result<Json<DeleteMemoryResponse>, McpError> {
        let key = clean_key(request.key)?;
        let deleted = self.db()?.delete_memory(&key).map_err(map_db_error)?;
        self.find_cache.clear();

        Ok(Json(DeleteMemoryResponse { key, deleted }))
    }

    #[tool(
        name = "find_memory",
        title = "Find Memory",
        description = "Retrieve durable memory BEFORE acting or answering. Always call this first. It can also be called without keyword, repo, or tags to load recent pocket-memory context.

Search strategy (apply in order):
1. If you know the current repo: ALWAYS include repo filter first.
2. If looking for a specific topic: add relevant tags (e.g., ['auth'] for authentication).
3. If you only know rough words: add keyword with a few terms. The search matches any term across key, content, repo, and tags.
4. If you do not know the repo or terms yet: call without filters to inspect recent memory context.

Parameter guidelines:
- repo: The current working repository name. This is the most important filter.
- tags: Use to narrow scope. Multiple tags use AND logic (all must match).
- keyword: Use broad word search across key, content, repo, and tags. Multiple words are searched broadly.
- limit: Default 10. Increase only if you need broad context.",
        annotations(
            title = "Find Memory",
            read_only_hint = true,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    fn find_memory(
        &self,
        Parameters(request): Parameters<FindMemoryRequest>,
    ) -> Result<Json<FindMemoryResponse>, McpError> {
        let keyword = request
            .keyword
            .map(|keyword| keyword.trim().to_owned())
            .filter(|keyword| !keyword.is_empty());
        let repo = clean_optional(request.repo);
        let tags = clean_tags(request.tags)?;

        let limit = request.limit.unwrap_or(10);
        let cache_key = find_cache_key(keyword.as_deref(), repo.as_deref(), &tags, limit);
        if let Some(memories) = self.find_cache.get(&cache_key) {
            return Ok(Json(FindMemoryResponse { memories }));
        }

        let memories = self
            .db()?
            .find_memories(keyword.as_deref(), repo.as_deref(), &tags, limit)
            .map_err(map_db_error)?;
        self.find_cache
            .insert_with_ttl(cache_key, memories.clone(), self.find_cache_ttl);

        Ok(Json(FindMemoryResponse { memories }))
    }
}

#[tool_handler]
impl ServerHandler for PocketMemoryServer {
    fn get_info(&self) -> ServerInfo {
        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
            .with_instructions(
                "You are working with a durable memory system. Follow this workflow STRICTLY:

1. BEFORE starting any task: call find_memory with repo=<current_repo> to load existing context.
2. If the current repo is unknown, call find_memory without filters or with a few broad keyword terms to inspect recent context.
3. BEFORE answering questions about project conventions, past decisions, or bugs: call find_memory first.
4. AFTER learning something that should survive across sessions (conventions, decisions, bugs, preferences, constraints): call upsert_memory immediately.
5. NEVER store transient information (current file contents, temporary thoughts, or information easily found in code).
6. For tags, prefer consistent short lowercase tags. Standard tags are recommended, but custom tags are allowed when they describe the memory better.
7. Keys must be kebab-case and descriptive (e.g., 'auth-jwt-strategy', 'db-migration-policy').",
            )
    }
}

fn clean_required(field: &'static str, value: String) -> Result<String, McpError> {
    let value = value.trim().to_owned();
    if value.is_empty() {
        return Err(McpError::invalid_params(
            format!("{field} must not be empty"),
            None,
        ));
    }

    Ok(value)
}

fn clean_key(value: String) -> Result<String, McpError> {
    let value = clean_required("key", value)?;
    if !is_kebab_case(&value) {
        return Err(McpError::invalid_params(
            "key must be kebab-case using lowercase letters, numbers, and single hyphens",
            None,
        ));
    }

    Ok(value)
}

fn clean_optional(value: Option<String>) -> Option<String> {
    value
        .map(|value| value.trim().to_owned())
        .filter(|value| !value.is_empty())
}

fn clean_tags(value: Option<Vec<String>>) -> Result<Vec<String>, McpError> {
    let tags = value
        .unwrap_or_default()
        .into_iter()
        .map(normalize_tag)
        .filter(|tag| !tag.is_empty())
        .collect::<BTreeSet<_>>()
        .into_iter()
        .collect::<Vec<_>>();

    if tags.len() > 10 {
        return Err(McpError::invalid_params(
            "tags must include at most 10 values",
            None,
        ));
    }

    if let Some(tag) = tags.iter().find(|tag| !is_valid_tag(tag)) {
        return Err(McpError::invalid_params(
            format!("invalid tag: {tag}"),
            None,
        ));
    }

    Ok(tags)
}

fn find_cache_key(
    keyword: Option<&str>,
    repo: Option<&str>,
    tags: &[String],
    limit: usize,
) -> String {
    format!(
        "keyword={}|repo={}|tags={}|limit={}",
        keyword.unwrap_or_default().trim().to_lowercase(),
        repo.unwrap_or_default().trim().to_lowercase(),
        tags.join(","),
        limit.clamp(1, 50),
    )
}

fn find_cache_capacity() -> usize {
    env::var(FIND_CACHE_CAPACITY_ENV)
        .ok()
        .and_then(|value| value.parse::<usize>().ok())
        .filter(|value| *value > 0)
        .unwrap_or(DEFAULT_FIND_CACHE_CAPACITY)
}

fn find_cache_ttl() -> Duration {
    let secs = env::var(FIND_CACHE_TTL_SECS_ENV)
        .ok()
        .and_then(|value| value.parse::<u64>().ok())
        .unwrap_or(DEFAULT_FIND_CACHE_TTL_SECS);

    Duration::from_secs(secs)
}

fn normalize_tag(tag: String) -> String {
    let mut normalized = String::new();
    let mut previous_hyphen = false;

    for ch in tag.trim().to_lowercase().chars() {
        let ch = match ch {
            'a'..='z' | '0'..='9' => ch,
            '-' | '_' | ' ' | '\t' => '-',
            _ => continue,
        };

        if ch == '-' {
            if normalized.is_empty() || previous_hyphen {
                continue;
            }
            previous_hyphen = true;
        } else {
            previous_hyphen = false;
        }

        normalized.push(ch);
    }

    normalized.trim_matches('-').to_owned()
}

fn is_kebab_case(value: &str) -> bool {
    let mut previous_hyphen = false;
    let mut has_char = false;

    for ch in value.chars() {
        match ch {
            'a'..='z' | '0'..='9' => {
                previous_hyphen = false;
                has_char = true;
            }
            '-' if has_char && !previous_hyphen => {
                previous_hyphen = true;
            }
            _ => return false,
        }
    }

    has_char && !previous_hyphen
}

fn is_valid_tag(tag: &str) -> bool {
    !tag.is_empty()
        && tag.len() <= 64
        && tag
            .chars()
            .all(|ch| matches!(ch, 'a'..='z' | '0'..='9' | '-'))
        && !tag.starts_with('-')
        && !tag.ends_with('-')
        && !tag.contains("--")
}

fn map_db_error(error: rusqlite::Error) -> McpError {
    McpError::internal_error(format!("database error: {error}"), None)
}

#[cfg(test)]
mod tests {
    use super::*;
    use rmcp::handler::server::wrapper::Parameters;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn clean_key_accepts_kebab_case() {
        assert_eq!(
            clean_key("repo-context-rule".into()).unwrap(),
            "repo-context-rule"
        );
        assert_eq!(
            clean_key("auth-v2-policy".into()).unwrap(),
            "auth-v2-policy"
        );
    }

    #[test]
    fn clean_key_rejects_non_kebab_case() {
        assert!(clean_key("RepoContextRule".into()).is_err());
        assert!(clean_key("repo_context_rule".into()).is_err());
        assert!(clean_key("repo--context".into()).is_err());
    }

    #[test]
    fn clean_tags_accepts_optional_custom_tags() {
        assert_eq!(
            clean_tags(Some(vec![
                "Convention".into(),
                "edge case".into(),
                "long_text".into(),
                "security".into(),
            ]))
            .unwrap(),
            vec!["convention", "edge-case", "long-text", "security"]
        );
        assert!(clean_tags(None).unwrap().is_empty());
        assert!(
            clean_tags(Some(vec![
                "bug".into(),
                "auth".into(),
                "testing".into(),
                "decision".into(),
                "security".into(),
                "unicode".into(),
                "edge-case".into(),
                "long-text".into(),
                "convention".into(),
                "preference".into(),
                "performance".into(),
            ]),)
            .is_err()
        );
    }

    #[test]
    fn find_cache_key_is_normalized() {
        assert_eq!(
            find_cache_key(
                Some(" Gateway Auth "),
                Some(" Test Repo "),
                &["auth".into(), "decision".into()],
                99,
            ),
            "keyword=gateway auth|repo=test repo|tags=auth,decision|limit=50"
        );
    }

    #[test]
    fn upsert_clears_find_cache() {
        let db_path = std::env::temp_dir().join(format!(
            "mcp-pocket-memory-cache-{}.sqlite3",
            std::process::id()
        ));
        let _ = std::fs::remove_file(&db_path);
        Database::open(&db_path).unwrap();
        let server = PocketMemoryServer::new(&db_path);

        let before = server
            .find_memory(Parameters(FindMemoryRequest {
                keyword: None,
                repo: Some("test-repo".into()),
                tags: None,
                limit: Some(10),
            }))
            .unwrap();
        assert!(before.0.memories.is_empty());

        server
            .upsert_memory(Parameters(UpsertMemoryRequest {
                key: "cache-invalidation-test".into(),
                repo: Some("test-repo".into()),
                tags: Some(vec!["cache".into()]),
                content: "cache invalidation should expose new memory".into(),
            }))
            .unwrap();

        let after = server
            .find_memory(Parameters(FindMemoryRequest {
                keyword: None,
                repo: Some("test-repo".into()),
                tags: None,
                limit: Some(10),
            }))
            .unwrap();

        let _ = std::fs::remove_file(&db_path);
        let _ = std::fs::remove_file(db_path.with_extension("sqlite3-wal"));
        let _ = std::fs::remove_file(db_path.with_extension("sqlite3-shm"));

        assert_eq!(after.0.memories.len(), 1);
        assert_eq!(after.0.memories[0].key, "cache-invalidation-test");
    }

    #[test]
    fn find_cache_config_uses_env_overrides() {
        let _lock = ENV_LOCK.lock().unwrap();
        unsafe {
            env::set_var(FIND_CACHE_CAPACITY_ENV, "42");
            env::set_var(FIND_CACHE_TTL_SECS_ENV, "7");
        }

        assert_eq!(find_cache_capacity(), 42);
        assert_eq!(find_cache_ttl(), Duration::from_secs(7));

        unsafe {
            env::remove_var(FIND_CACHE_CAPACITY_ENV);
            env::remove_var(FIND_CACHE_TTL_SECS_ENV);
        }
    }

    #[test]
    fn find_cache_config_falls_back_for_invalid_env() {
        let _lock = ENV_LOCK.lock().unwrap();
        unsafe {
            env::set_var(FIND_CACHE_CAPACITY_ENV, "0");
            env::set_var(FIND_CACHE_TTL_SECS_ENV, "invalid");
        }

        assert_eq!(find_cache_capacity(), DEFAULT_FIND_CACHE_CAPACITY);
        assert_eq!(
            find_cache_ttl(),
            Duration::from_secs(DEFAULT_FIND_CACHE_TTL_SECS)
        );

        unsafe {
            env::remove_var(FIND_CACHE_CAPACITY_ENV);
            env::remove_var(FIND_CACHE_TTL_SECS_ENV);
        }
    }
}