topodb-mcp 0.0.13

MCP server exposing the TopoDB agent-memory engine
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
//! CLI parsing and the server configuration contract.
//!
//! CLI: `topodb-mcp --db <path> [--scope <ulid|shared>]
//!      [--read-scopes <ulid|shared>[,...]] [--spec <spec.json>]
//!      [--allow-unscoped-changes] [--embeddings <off|model>]
//!      [--model-dir <path>] [--no-ort-download]`
//! - `--scope`: the default **write** scope — the scope a created node/edge is
//!   stamped with when a write tool omits `scope`. `"shared"` (case-insensitive)
//!   or omitted => [`Scope::Shared`]; any other value is parsed as a ULID.
//! - `--read-scopes`: the default **read** scope set — the scopes a read tool
//!   filters by when it omits `scope`/`scopes`. Comma-separated. Defaults to
//!   just `--scope`'s value, which is the single-scope behaviour every existing
//!   client relies on. A read filters by a *set*; a write picks *one* scope —
//!   hence two flags rather than one overloaded flag.
//! - `--spec`: path to a JSON file deserializing to [`IndexSpec`], honored
//!   verbatim (may reindex an existing db). Omitted => inherit the db's
//!   persisted spec on an existing file, or create a fresh db with the
//!   [built-in default spec](default_spec). See how `main` opens the db.
//! - `--allow-unscoped-changes`: a bare toggle enabling `get_changes`, the one
//!   unscoped read (the op log spans every scope in the db). Off by default —
//!   in a db shared across projects, an agent calling `get_changes` would
//!   otherwise replay every other project's writes. Sync/consolidation hosts
//!   that legitimately need the whole log pass this flag.
//! - `--embeddings <off|model>`: `off` (case-insensitive) permanently disables
//!   the embedder (`db_info` reports `Off` status forever, every write/search
//!   stays text-only). Omitted => auto: start the embedder with the built-in
//!   default model. Any other value is taken as a model name to start with —
//!   an unrecognized name still constructs an embedder, it just lands in
//!   `Failed` status (see `embedder::Embedder::start`) rather than refusing to
//!   start the server.
//! - `--model-dir <path>`: overrides where the embedding model is
//!   downloaded/cached. Omitted => `default_model_cache_dir()` (`main.rs`).
//! - `--no-ort-download`: disable the automatic ONNX Runtime dylib download;
//!   embeddings then require a system runtime or `ORT_DYLIB_PATH`.
//!
//! Arg parsing is hand-rolled: the surface is eight flags, so `clap` would add
//! a dependency and a proc-macro build for no real gain here.

use std::error::Error;
use std::path::PathBuf;
use std::str::FromStr;

use topodb::{IndexSpec, Scope, ScopeId};

/// The one canonical usage line, shared by `--help` and the unknown-argument
/// error so the two can never drift. `<off|auto|model>` matches the README
/// (an explicit `auto` spells out the default model).
const USAGE: &str = "usage: topodb-mcp --db <path> [--scope <ulid|shared>] [--read-scopes <ulid|shared>[,...]] [--spec <spec.json>] [--allow-unscoped-changes] [--embeddings <off|auto|model>] [--model-dir <path>] [--no-ort-download]";

/// Label/prop name constants, single-sourced in `topodb-json` (shared with
/// `topodb-cli`'s `create-entity`/`create-memory`) and re-exported here so
/// existing `topodb-mcp` call sites (`use crate::config::{ENTITY_LABEL, ...}`)
/// keep working unchanged.
pub use topodb_json::{
    ALIAS_EDGE_TYPE, ALIAS_LABEL, ALIAS_NAME_PROP, ENTITY_LABEL, ENTITY_NAME_PROP,
    MEMORY_CONTENT_PROP, MEMORY_LABEL, SYNONYM_EXPANSION_PROP, SYNONYM_LABEL, SYNONYM_TERM_PROP,
};

/// A **non-empty** set of scopes a read filters by. The non-empty invariant is
/// structural rather than conventional: an empty [`ScopeSet`] admits nothing, so
/// an empty read set would make every default read silently return empty. There
/// is no unscoped read, and "read nothing" is never what a caller means.
///
/// Distinct from [`Config::default_scope`], the single [`Scope`] a *write* is
/// stamped with. A read filters by a set; a write picks one.
///
/// [`ScopeSet`]: topodb::ScopeSet
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadScopes(Vec<Scope>);

impl ReadScopes {
    /// Rejects an empty list. This is the only constructor.
    pub fn new(scopes: Vec<Scope>) -> Result<Self, Box<dyn Error>> {
        if scopes.is_empty() {
            return Err(
                "read scope set is empty; expected at least one of \"shared\" or a scope ULID"
                    .into(),
            );
        }
        Ok(Self(scopes))
    }

    /// The scopes, in the order given.
    pub fn as_slice(&self) -> &[Scope] {
        &self.0
    }
}

/// Resolved server configuration (see the module docs for the CLI contract).
#[derive(Debug, Clone)]
pub struct Config {
    pub db_path: PathBuf,
    pub default_scope: Scope,
    /// The default read `ScopeSet`, as a non-empty list of scopes. Seeded from
    /// `--read-scopes`, or from `--scope` alone when that flag is omitted (so
    /// the single-scope behaviour every existing client relies on is preserved
    /// exactly). Distinct from `default_scope`, which is the single scope a
    /// *write* is stamped with — a read filters by a set, a write picks one.
    pub default_read_scopes: ReadScopes,
    /// The spec parsed from an explicit `--spec` file, or `None` when the flag
    /// was omitted. `None` means "inherit the db's persisted spec" (see how
    /// `main` opens the db), NOT "use `default_spec()`" — the two diverge for
    /// an existing db: silently substituting the default would reindex it and
    /// drop its declared equality indexes.
    pub spec: Option<IndexSpec>,
    /// Opt-in for `get_changes`, the one unscoped read — the op log spans every
    /// scope in the db, so in a db shared across projects it is a cross-project
    /// read of everything. Off unless the host explicitly asks for it. Sync and
    /// consolidation hosts, which legitimately need the whole log, pass
    /// `--allow-unscoped-changes`.
    pub allow_unscoped_changes: bool,
    /// `--embeddings <off|model>`. `None` (flag omitted) => auto: start the
    /// embedder with the built-in default model. `Some("off")`
    /// (case-insensitive) => the embedder is permanently disabled. `Some(other)`
    /// => start the embedder with that model name (unknown names still
    /// construct an `Embedder`, just one that lands in `Failed` status — see
    /// `embedder::Embedder::start`).
    pub embeddings: Option<String>,
    /// `--model-dir <path>`: overrides the embedding model cache directory.
    /// `None` => `default_model_cache_dir()` in `main`.
    pub model_dir: Option<PathBuf>,
    /// --no-ort-download: disable the automatic ONNX Runtime dylib download;
    /// embeddings then require a system runtime or ORT_DYLIB_PATH.
    pub no_ort_download: bool,
}

/// The built-in default index spec used when `--spec` is omitted: equality on
/// `(Entity, name)`, text on `(Memory, content)`. Single-sourced in
/// `topodb-json` (shared with `topodb-cli`'s fresh-db bootstrap) so a
/// CLI-created db and an MCP-created db carry a byte-identical persisted
/// `index_spec` — either front end can serve the other's db via `open_stored`
/// with no reindex and no mis-declared index. Re-exported here so existing
/// `topodb-mcp` call sites (`config::default_spec()`) keep working unchanged.
pub use topodb_json::default_spec;

/// Human/JSON-facing rendering of a [`Scope`]: `"shared"` or the ULID string.
/// Single-sourced in `topodb-json`; re-exported here so existing
/// `topodb-mcp` call sites keep working unchanged.
pub use topodb_json::scope_label;

/// Parses a `--scope` value: `"shared"` (any case) => [`Scope::Shared`],
/// otherwise a ULID string => [`Scope::Id`].
fn parse_scope(s: &str) -> Result<Scope, Box<dyn Error>> {
    if s.eq_ignore_ascii_case("shared") {
        Ok(Scope::Shared)
    } else {
        let id = ScopeId::from_str(s).map_err(|e| {
            format!("invalid --scope value {s:?} (expected \"shared\" or a ULID): {e}")
        })?;
        Ok(Scope::Id(id))
    }
}

/// Parses a `--read-scopes` value: a comma-separated list of `shared` / ULID
/// entries, whitespace around each entry ignored. Rejects an empty list — an
/// empty `ScopeSet` admits nothing, and "read nothing" is never what a caller
/// means (there is no unscoped read).
fn parse_read_scopes(s: &str) -> Result<ReadScopes, Box<dyn Error>> {
    let scopes = s
        .split(',')
        .map(str::trim)
        .filter(|part| !part.is_empty())
        .map(parse_scope)
        .collect::<Result<Vec<_>, _>>()?;
    if scopes.is_empty() {
        return Err(format!(
            "--read-scopes value {s:?} is empty; expected a comma-separated list of \"shared\" or scope ULIDs"
        )
        .into());
    }
    ReadScopes::new(scopes)
}

impl Config {
    /// Parses config from an argument iterator (excluding argv[0]). Returns a
    /// clear error for missing values, unknown flags, or a missing/invalid
    /// `--spec` file. Does NOT touch the filesystem for `--db` — the parent-dir
    /// check lives in `main` so this stays a pure parse.
    pub fn from_args<I>(args: I) -> Result<Self, Box<dyn Error>>
    where
        I: IntoIterator<Item = String>,
    {
        let mut db_path: Option<PathBuf> = None;
        let mut scope: Option<String> = None;
        let mut read_scopes: Option<String> = None;
        let mut spec_path: Option<PathBuf> = None;
        let mut allow_unscoped_changes = false;
        let mut embeddings: Option<String> = None;
        let mut model_dir: Option<PathBuf> = None;
        let mut no_ort_download = false;

        let mut it = args.into_iter();
        while let Some(arg) = it.next() {
            match arg.as_str() {
                "--help" | "-h" => {
                    println!("{USAGE}");
                    std::process::exit(0);
                }
                "--version" | "-V" => {
                    println!("topodb-mcp {}", env!("CARGO_PKG_VERSION"));
                    std::process::exit(0);
                }
                "--db" => {
                    db_path = Some(it.next().ok_or("--db requires a <path> value")?.into());
                }
                "--scope" => {
                    scope = Some(it.next().ok_or("--scope requires a <ulid|shared> value")?);
                }
                "--read-scopes" => {
                    read_scopes = Some(
                        it.next()
                            .ok_or("--read-scopes requires a comma-separated <ulid|shared> list")?,
                    );
                }
                "--spec" => {
                    spec_path = Some(
                        it.next()
                            .ok_or("--spec requires a <spec.json> value")?
                            .into(),
                    );
                }
                "--allow-unscoped-changes" => {
                    allow_unscoped_changes = true;
                }
                "--embeddings" => {
                    embeddings = Some(
                        it.next()
                            .ok_or("--embeddings requires an <off|model> value")?,
                    );
                }
                "--model-dir" => {
                    model_dir = Some(
                        it.next()
                            .ok_or("--model-dir requires a <path> value")?
                            .into(),
                    );
                }
                "--no-ort-download" => {
                    no_ort_download = true;
                }
                other => {
                    return Err(format!("unknown argument {other:?}; {USAGE}").into());
                }
            }
        }

        let db_path = db_path.ok_or("missing required --db <path>")?;
        let default_scope = match scope {
            Some(s) => parse_scope(&s)?,
            None => Scope::Shared,
        };
        let default_read_scopes = match read_scopes {
            Some(s) => parse_read_scopes(&s)?,
            None => ReadScopes::new(vec![default_scope])?,
        };
        let spec = match spec_path {
            Some(p) => {
                let text = std::fs::read_to_string(&p)
                    .map_err(|e| format!("reading --spec {}: {e}", p.display()))?;
                Some(serde_json::from_str(&text).map_err(|e| {
                    format!("parsing --spec {} as IndexSpec JSON: {e}", p.display())
                })?)
            }
            None => None,
        };

        Ok(Config {
            db_path,
            default_scope,
            default_read_scopes,
            spec,
            allow_unscoped_changes,
            embeddings,
            model_dir,
            no_ort_download,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn argv(items: &[&str]) -> Vec<String> {
        items.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn defaults_scope_shared_and_no_spec() {
        let cfg = Config::from_args(argv(&["--db", "t.redb"])).unwrap();
        assert_eq!(cfg.db_path, PathBuf::from("t.redb"));
        assert!(matches!(cfg.default_scope, Scope::Shared));
        // No `--spec` => None ("inherit the db's persisted spec"), NOT
        // default_spec(): `main` only falls back to the default for a fresh db.
        assert!(cfg.spec.is_none());
    }

    #[test]
    fn explicit_spec_flag_is_parsed_to_some() {
        let dir = tempfile::tempdir().unwrap();
        let p = dir.path().join("spec.json");
        std::fs::write(
            &p,
            r#"{"equality":[{"label":"Person","prop":"handle"}],"text":[]}"#,
        )
        .unwrap();
        let cfg =
            Config::from_args(argv(&["--db", "t.redb", "--spec", p.to_str().unwrap()])).unwrap();
        let spec = cfg.spec.expect("--spec should parse to Some");
        assert_eq!(spec.equality.len(), 1);
        assert_eq!(spec.equality[0].label, "Person");
        assert_eq!(spec.equality[0].prop, "handle");
        assert!(spec.text.is_empty());
    }

    #[test]
    fn scope_shared_is_case_insensitive() {
        let cfg = Config::from_args(argv(&["--db", "t.redb", "--scope", "SHARED"])).unwrap();
        assert!(matches!(cfg.default_scope, Scope::Shared));
    }

    #[test]
    fn scope_ulid_parses_to_id_and_round_trips_label() {
        let id = ScopeId::new();
        let s = id.to_string();
        let cfg = Config::from_args(argv(&["--db", "t.redb", "--scope", &s])).unwrap();
        match cfg.default_scope {
            Scope::Id(got) => assert_eq!(got, id),
            other => panic!("expected Scope::Id, got {other:?}"),
        }
        assert_eq!(scope_label(&cfg.default_scope), s);
    }

    #[test]
    fn scope_label_shared() {
        assert_eq!(scope_label(&Scope::Shared), "shared");
    }

    #[test]
    fn bad_scope_is_rejected() {
        assert!(Config::from_args(argv(&["--db", "t.redb", "--scope", "not-a-ulid"])).is_err());
    }

    #[test]
    fn missing_db_is_rejected() {
        assert!(Config::from_args(argv(&["--scope", "shared"])).is_err());
    }

    #[test]
    fn unknown_flag_is_rejected() {
        assert!(Config::from_args(argv(&["--db", "t.redb", "--nope"])).is_err());
    }

    #[test]
    fn missing_value_is_rejected() {
        assert!(Config::from_args(argv(&["--db"])).is_err());
    }

    #[test]
    fn read_scopes_defaults_to_the_write_scope() {
        let id = ScopeId::new();
        let s = id.to_string();
        let cfg = Config::from_args(argv(&["--db", "t.redb", "--scope", &s])).unwrap();
        assert_eq!(cfg.default_read_scopes.as_slice(), &[Scope::Id(id)]);
    }

    #[test]
    fn read_scopes_defaults_to_shared_when_scope_omitted() {
        let cfg = Config::from_args(argv(&["--db", "t.redb"])).unwrap();
        assert_eq!(cfg.default_read_scopes.as_slice(), &[Scope::Shared]);
    }

    #[test]
    fn read_scopes_parses_a_comma_separated_list() {
        let a = ScopeId::new();
        let list = format!("{a},shared");
        let cfg = Config::from_args(argv(&[
            "--db",
            "t.redb",
            "--scope",
            &a.to_string(),
            "--read-scopes",
            &list,
        ]))
        .unwrap();
        assert_eq!(
            cfg.default_read_scopes.as_slice(),
            &[Scope::Id(a), Scope::Shared]
        );
        // The write scope is untouched by --read-scopes.
        assert!(matches!(cfg.default_scope, Scope::Id(got) if got == a));
    }

    #[test]
    fn read_scopes_tolerates_whitespace_around_entries() {
        let a = ScopeId::new();
        let list = format!(" {a} , shared ");
        let cfg = Config::from_args(argv(&["--db", "t.redb", "--read-scopes", &list])).unwrap();
        assert_eq!(
            cfg.default_read_scopes.as_slice(),
            &[Scope::Id(a), Scope::Shared]
        );
    }

    #[test]
    fn read_scopes_rejects_a_bad_ulid() {
        assert!(Config::from_args(argv(&[
            "--db",
            "t.redb",
            "--read-scopes",
            "shared,not-a-ulid"
        ]))
        .is_err());
    }

    #[test]
    fn read_scopes_rejects_an_empty_list() {
        // An empty read set admits nothing — there is no unscoped read, so this is
        // a caller error, not "read everything".
        assert!(Config::from_args(argv(&["--db", "t.redb", "--read-scopes", ""])).is_err());
        assert!(Config::from_args(argv(&["--db", "t.redb", "--read-scopes", " , "])).is_err());
    }

    #[test]
    fn read_scopes_type_rejects_an_empty_set() {
        // The invariant is structural, not a parser convention: even a direct
        // construction cannot produce an empty read set. An empty ScopeSet
        // admits nothing, so every default read would silently return empty.
        assert!(ReadScopes::new(vec![]).is_err());
        assert!(ReadScopes::new(vec![Scope::Shared]).is_ok());
        assert!(ReadScopes::new(vec![Scope::Id(ScopeId::new()), Scope::Shared]).is_ok());
    }

    #[test]
    fn read_scopes_preserves_order_and_contents() {
        let a = ScopeId::new();
        let rs = ReadScopes::new(vec![Scope::Id(a), Scope::Shared]).unwrap();
        assert_eq!(rs.as_slice(), &[Scope::Id(a), Scope::Shared]);
    }

    #[test]
    fn unscoped_changes_is_off_by_default() {
        let cfg = Config::from_args(argv(&["--db", "t.redb"])).unwrap();
        assert!(!cfg.allow_unscoped_changes);
    }

    #[test]
    fn unscoped_changes_flag_is_a_bare_toggle() {
        let cfg = Config::from_args(argv(&["--db", "t.redb", "--allow-unscoped-changes"])).unwrap();
        assert!(cfg.allow_unscoped_changes);
    }

    #[test]
    fn ort_download_is_on_by_default() {
        let cfg = Config::from_args(argv(&["--db", "x.redb"])).unwrap();
        assert!(!cfg.no_ort_download);
    }

    #[test]
    fn no_ort_download_flag_is_a_bare_toggle() {
        let cfg = Config::from_args(argv(&["--db", "x.redb", "--no-ort-download"])).unwrap();
        assert!(cfg.no_ort_download);
    }
}