Skip to main content

somatize_mcp/
context.rs

1//! Soma MCP server context: holds state and implements tool handlers.
2
3use crate::protocol::ToolCallResult;
4use serde_json::json;
5use somatize_memory::{ExperimentRecord, FileKnowledgeBase, KnowledgeBase, MemoryKnowledgeBase};
6use std::path::{Path, PathBuf};
7
8/// Server-side state for the Soma MCP server.
9pub struct SomaContext {
10    /// Project directory (where user's filter code lives).
11    pub project_dir: PathBuf,
12    /// Knowledge base for experiment tracking.
13    pub kb: Box<dyn KnowledgeBase>,
14    /// Where the knowledge base is persisted, when it is.
15    kb_path: Option<PathBuf>,
16}
17
18impl SomaContext {
19    /// Create a context with a persistent knowledge base when one is
20    /// available: `SOMA_KB_PATH` if set, else the project's
21    /// `.soma/experiments.jsonl` when a `.soma/` directory exists.
22    /// Falls back to the in-memory KB otherwise (records are lost on
23    /// server exit).
24    pub fn new(project_dir: impl Into<PathBuf>) -> Self {
25        Self::with_env_override(project_dir, std::env::var("SOMA_KB_PATH").ok())
26    }
27
28    /// Deterministic constructor: `env_override` plays the role of the
29    /// `SOMA_KB_PATH` environment variable. Used by `new()` and by
30    /// tests, which must never depend on (or leak through) the real
31    /// process environment.
32    pub fn with_env_override(
33        project_dir: impl Into<PathBuf>,
34        env_override: Option<String>,
35    ) -> Self {
36        let project_dir = project_dir.into();
37        let resolved = Self::kb_path(&project_dir, env_override);
38        let mut kb_path = None;
39        let kb: Box<dyn KnowledgeBase> = match &resolved {
40            Some(path) => match FileKnowledgeBase::open(path) {
41                Ok(kb) => {
42                    kb_path = resolved.clone();
43                    Box::new(kb)
44                }
45                Err(e) => {
46                    eprintln!(
47                        "soma-mcp: failed to open knowledge base at {} ({e}); using in-memory",
48                        path.display()
49                    );
50                    Box::new(MemoryKnowledgeBase::new())
51                }
52            },
53            None => Box::new(MemoryKnowledgeBase::new()),
54        };
55        Self {
56            project_dir,
57            kb,
58            kb_path,
59        }
60    }
61
62    /// Pick up experiments another process appended since the last
63    /// call. An MCP server outlives many training runs; without this it
64    /// answers every question from the snapshot it loaded at startup.
65    pub fn refresh_kb(&mut self) {
66        if let Err(e) = self.kb.refresh() {
67            eprintln!("soma-mcp: could not refresh the knowledge base: {e}");
68        }
69    }
70
71    /// Human-readable location of the journal, when it has one.
72    pub fn kb_location(&self) -> Option<String> {
73        self.kb_path.as_ref().map(|p| p.display().to_string())
74    }
75
76    /// The tracking root this project's runs live under (`.soma`).
77    pub fn tracking_root(&self) -> PathBuf {
78        self.project_dir.join(".soma")
79    }
80
81    /// Where the persistent KB lives, if anywhere: the explicit
82    /// override wins; otherwise a `.soma/` DIRECTORY in the project
83    /// enables `.soma/experiments.jsonl`.
84    fn kb_path(project_dir: &Path, env_override: Option<String>) -> Option<PathBuf> {
85        if let Some(path) = env_override.filter(|p| !p.is_empty()) {
86            return Some(PathBuf::from(path));
87        }
88        let soma_dir = project_dir.join(".soma");
89        soma_dir
90            .is_dir()
91            .then(|| soma_dir.join("experiments.jsonl"))
92    }
93
94    // ═══════════════════════════════════════
95    // Code tools
96    // ═══════════════════════════════════════
97
98    /// `list_filters`: every `.py`/`.rs` file under `path` (default:
99    /// the project directory), recursing but skipping hidden
100    /// directories. Returns a JSON array of `{"path": ...}` objects.
101    ///
102    /// Like every handler below, it takes the raw `arguments` object
103    /// from `tools/call` and reports a missing or bad argument as a
104    /// [`ToolCallResult::error`] — the model reads the message and can
105    /// retry, which a protocol-level error would not allow.
106    pub fn list_filters(&self, params: &serde_json::Value) -> ToolCallResult {
107        let dir = params
108            .get("path")
109            .and_then(|v| v.as_str())
110            .map(PathBuf::from)
111            .unwrap_or_else(|| self.project_dir.clone());
112
113        match find_filter_files(&dir) {
114            Ok(files) => {
115                let result: Vec<serde_json::Value> = files
116                    .iter()
117                    .map(|f| json!({ "path": f.to_string_lossy() }))
118                    .collect();
119                ToolCallResult::text(serde_json::to_string_pretty(&result).unwrap_or_default())
120            }
121            Err(e) => ToolCallResult::error(format!("Failed to list filters: {e}")),
122        }
123    }
124
125    /// `read_filter_source`: the contents of `file_path`, resolved
126    /// against the project directory when relative.
127    pub fn read_filter_source(&self, params: &serde_json::Value) -> ToolCallResult {
128        let Some(path) = params.get("file_path").and_then(|v| v.as_str()) else {
129            return ToolCallResult::error("Missing required parameter: file_path");
130        };
131
132        let full_path = self.resolve_path(path);
133        match std::fs::read_to_string(&full_path) {
134            Ok(content) => ToolCallResult::text(content),
135            Err(e) => ToolCallResult::error(format!("Failed to read {}: {e}", full_path.display())),
136        }
137    }
138
139    /// `write_filter_source`: write `content` to `file_path`, creating
140    /// parent directories as needed. An existing file is copied to a
141    /// `.bak` sibling first — a model editing code deserves one level
142    /// of undo.
143    pub fn write_filter_source(&self, params: &serde_json::Value) -> ToolCallResult {
144        let Some(path) = params.get("file_path").and_then(|v| v.as_str()) else {
145            return ToolCallResult::error("Missing required parameter: file_path");
146        };
147        let Some(content) = params.get("content").and_then(|v| v.as_str()) else {
148            return ToolCallResult::error("Missing required parameter: content");
149        };
150
151        let full_path = self.resolve_path(path);
152
153        // Create backup if file exists
154        if full_path.exists() {
155            let backup = full_path.with_extension("bak");
156            if let Err(e) = std::fs::copy(&full_path, &backup) {
157                return ToolCallResult::error(format!("Failed to create backup: {e}"));
158            }
159        }
160
161        // Ensure parent directory exists
162        if let Some(parent) = full_path.parent() {
163            let _ = std::fs::create_dir_all(parent);
164        }
165
166        match std::fs::write(&full_path, content) {
167            Ok(()) => ToolCallResult::text(format!(
168                "Written {} bytes to {}",
169                content.len(),
170                full_path.display()
171            )),
172            Err(e) => ToolCallResult::error(format!("Failed to write: {e}")),
173        }
174    }
175
176    // ═══════════════════════════════════════
177    // Execution tools
178    // ═══════════════════════════════════════
179
180    /// `run_pipeline`: build the graph the model described and run it.
181    ///
182    /// The nodes name filters that live in the project — the same files
183    /// `list_filters` lists and `read_filter_source` reads — so the loop
184    /// a model works in is closed: read the code, write a variant, run
185    /// it, read the result out of the experiment pool.
186    ///
187    /// Tracked by default, so the run lands in `.soma/experiments.jsonl`
188    /// with a lineage and `kb_summarize_run` can be pointed at it.
189    pub fn run_pipeline(&self, params: &serde_json::Value) -> ToolCallResult {
190        let Some(nodes) = params.get("nodes").and_then(|v| v.as_array()) else {
191            return ToolCallResult::error(
192                "Missing required parameter: nodes (a list of \
193                 {id, filter, config})",
194            );
195        };
196        if nodes.is_empty() {
197            return ToolCallResult::error("`nodes` is empty: there is no graph to run");
198        }
199        let mut spec = params.clone();
200        spec["kind"] = json!("pipeline");
201        match crate::exec::GraphRunner::new(&self.project_dir).run(&spec) {
202            Ok(payload) => ToolCallResult::text(crate::render::render_pipeline_run(&payload)),
203            Err(e) => ToolCallResult::error(e),
204        }
205    }
206
207    /// `run_study`: the same graph spec, searched.
208    ///
209    /// The search space is not a second vocabulary — a node config value
210    /// written as `{"__search__": {...}}` becomes a dimension, and
211    /// `graph.search_space()` finds it. So the difference between running
212    /// a graph once and searching it is which values were marked.
213    pub fn run_study(&self, params: &serde_json::Value) -> ToolCallResult {
214        let Some(nodes) = params.get("nodes").and_then(|v| v.as_array()) else {
215            return ToolCallResult::error(
216                "Missing required parameter: nodes (a list of \
217                 {id, filter, config}); mark the values to search with \
218                 {\"__search__\": {\"low\": …, \"high\": …}}",
219            );
220        };
221        if nodes.is_empty() {
222            return ToolCallResult::error("`nodes` is empty: there is no graph to search");
223        }
224        let mut spec = params.clone();
225        spec["kind"] = json!("study");
226        match crate::exec::GraphRunner::new(&self.project_dir).run(&spec) {
227            Ok(payload) => ToolCallResult::text(crate::render::render_study_run(&payload)),
228            Err(e) => ToolCallResult::error(e),
229        }
230    }
231
232    // ═══════════════════════════════════════
233    // Knowledge tools
234    // ═══════════════════════════════════════
235
236    /// `record_experiment`: append an experiment to the knowledge base.
237    /// `id` and `name` are required; hypothesis, research line,
238    /// pipeline summary, parent, notes, tags, metrics and params are
239    /// taken when present and silently skipped when absent or of the
240    /// wrong JSON type — a partial record beats no record.
241    pub fn record_experiment(&mut self, params: &serde_json::Value) -> ToolCallResult {
242        let Some(id) = params.get("id").and_then(|v| v.as_str()) else {
243            return ToolCallResult::error("Missing required parameter: id");
244        };
245        let Some(name) = params.get("name").and_then(|v| v.as_str()) else {
246            return ToolCallResult::error("Missing required parameter: name");
247        };
248
249        let mut record = ExperimentRecord::new(id, name);
250
251        if let Some(h) = params.get("hypothesis").and_then(|v| v.as_str()) {
252            record = record.with_hypothesis(h);
253        }
254        if let Some(l) = params.get("research_line").and_then(|v| v.as_str()) {
255            record = record.with_research_line(l);
256        }
257        if let Some(p) = params.get("pipeline_summary").and_then(|v| v.as_str()) {
258            record = record.with_pipeline(p);
259        }
260        if let Some(parent) = params.get("parent").and_then(|v| v.as_str()) {
261            record = record.with_parent(parent);
262        }
263        if let Some(notes) = params.get("notes").and_then(|v| v.as_str()) {
264            record = record.with_notes(notes);
265        }
266        if let Some(tags) = params.get("tags").and_then(|v| v.as_array()) {
267            let tags: Vec<String> = tags
268                .iter()
269                .filter_map(|t| t.as_str().map(String::from))
270                .collect();
271            record = record.with_tags(tags);
272        }
273        if let Some(metrics) = params.get("metrics").and_then(|v| v.as_object()) {
274            let m: std::collections::BTreeMap<String, f64> = metrics
275                .iter()
276                .filter_map(|(k, v)| v.as_f64().map(|val| (k.clone(), val)))
277                .collect();
278            record = record.with_metrics(m);
279        }
280        if let Some(p) = params.get("params").and_then(|v| v.as_object()) {
281            let params_map: std::collections::BTreeMap<String, serde_json::Value> =
282                p.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
283            record = record.with_params(params_map);
284        }
285
286        match self.kb.record(record) {
287            Ok(()) => ToolCallResult::text(format!("Experiment '{id}' recorded successfully")),
288            Err(e) => ToolCallResult::error(format!("Failed to record experiment: {e}")),
289        }
290    }
291
292    /// `query_knowledge_base`: free-text search over recorded
293    /// experiments (`query`, plus `max_results`, default 10). Returns a
294    /// JSON array of experiment summaries — id, name, hypothesis, line,
295    /// metrics, tags — enough to decide which one to ask more about.
296    pub fn query_knowledge_base(&self, params: &serde_json::Value) -> ToolCallResult {
297        let Some(query) = params.get("query").and_then(|v| v.as_str()) else {
298            return ToolCallResult::error("Missing required parameter: query");
299        };
300        let max = params
301            .get("max_results")
302            .and_then(|v| v.as_u64())
303            .unwrap_or(10) as usize;
304
305        match self.kb.search(query, max) {
306            Ok(results) => {
307                let items: Vec<serde_json::Value> = results
308                    .iter()
309                    .map(|e| {
310                        json!({
311                            "id": e.id,
312                            "name": e.name,
313                            "hypothesis": e.hypothesis,
314                            "research_line": e.research_line,
315                            "metrics": e.metrics,
316                            "tags": e.tags,
317                        })
318                    })
319                    .collect();
320                ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
321            }
322            Err(e) => ToolCallResult::error(format!("Query failed: {e}")),
323        }
324    }
325
326    /// `get_trajectory`: the values of `metric` across the experiments
327    /// of `research_line`, in recording order — how the line has been
328    /// moving, as `{experiment_id, value}` pairs.
329    pub fn get_trajectory(&self, params: &serde_json::Value) -> ToolCallResult {
330        let Some(line) = params.get("research_line").and_then(|v| v.as_str()) else {
331            return ToolCallResult::error("Missing: research_line");
332        };
333        let Some(metric) = params.get("metric").and_then(|v| v.as_str()) else {
334            return ToolCallResult::error("Missing: metric");
335        };
336
337        match self.kb.trajectory(line, metric) {
338            Ok(traj) => {
339                let items: Vec<serde_json::Value> = traj
340                    .iter()
341                    .map(|(id, val)| json!({"experiment_id": id, "value": val}))
342                    .collect();
343                ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
344            }
345            Err(e) => ToolCallResult::error(format!("Failed: {e}")),
346        }
347    }
348
349    /// `get_change_points`: the experiments where `metric` jumped by
350    /// more than `threshold` (default 0.05) within `research_line` —
351    /// the moments worth reading closely, with before/after values and
352    /// a description of each.
353    pub fn get_change_points(&self, params: &serde_json::Value) -> ToolCallResult {
354        let Some(line) = params.get("research_line").and_then(|v| v.as_str()) else {
355            return ToolCallResult::error("Missing: research_line");
356        };
357        let Some(metric) = params.get("metric").and_then(|v| v.as_str()) else {
358            return ToolCallResult::error("Missing: metric");
359        };
360        let threshold = params
361            .get("threshold")
362            .and_then(|v| v.as_f64())
363            .unwrap_or(0.05);
364
365        match self.kb.change_points(line, metric, threshold) {
366            Ok(points) => {
367                let items: Vec<serde_json::Value> = points
368                    .iter()
369                    .map(|cp| {
370                        json!({
371                            "experiment_id": cp.experiment_id,
372                            "metric": cp.metric_name,
373                            "before": cp.value_before,
374                            "after": cp.value_after,
375                            "description": cp.description,
376                        })
377                    })
378                    .collect();
379                ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
380            }
381            Err(e) => ToolCallResult::error(format!("Failed: {e}")),
382        }
383    }
384
385    /// `list_research_lines`: every research line in the pool, with its
386    /// trend, experiment count and best metric so far. Takes no
387    /// arguments.
388    pub fn list_research_lines(&self, _params: &serde_json::Value) -> ToolCallResult {
389        match self.kb.research_lines() {
390            Ok(lines) => {
391                let items: Vec<serde_json::Value> = lines
392                    .iter()
393                    .map(|l| {
394                        json!({
395                            "name": l.name,
396                            "trend": l.trend.to_string(),
397                            "experiments": l.experiments.len(),
398                            "best_metric": l.best_metric_value,
399                            "best_metric_name": l.best_metric_name,
400                        })
401                    })
402                    .collect();
403                ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
404            }
405            Err(e) => ToolCallResult::error(format!("Failed: {e}")),
406        }
407    }
408
409    /// `promising_lines`: the research lines with an improving trend,
410    /// or whose best result is on `metric` — where the next experiment
411    /// is most likely to pay off.
412    pub fn promising_lines(&self, params: &serde_json::Value) -> ToolCallResult {
413        let Some(metric) = params.get("metric").and_then(|v| v.as_str()) else {
414            return ToolCallResult::error("Missing: metric");
415        };
416
417        match self.kb.promising_lines(metric) {
418            Ok(lines) => {
419                let items: Vec<serde_json::Value> = lines
420                    .iter()
421                    .map(|l| json!({"name": l.name, "trend": l.trend.to_string(), "best": l.best_metric_value}))
422                    .collect();
423                ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
424            }
425            Err(e) => ToolCallResult::error(format!("Failed: {e}")),
426        }
427    }
428
429    // ═══════════════════════════════════════
430    // Project tools
431    // ═══════════════════════════════════════
432
433    /// `create_research_line`: start a named line by recording a
434    /// `<name>_init` marker experiment carrying the description. Lines
435    /// have no existence of their own in the pool — they are the set of
436    /// experiments tagged with them, so creating one means recording
437    /// one.
438    pub fn create_research_line(&mut self, params: &serde_json::Value) -> ToolCallResult {
439        let Some(name) = params.get("name").and_then(|v| v.as_str()) else {
440            return ToolCallResult::error("Missing: name");
441        };
442        let description = params
443            .get("description")
444            .and_then(|v| v.as_str())
445            .unwrap_or("");
446
447        // Record a marker experiment for the line
448        let record =
449            ExperimentRecord::new(format!("{name}_init"), format!("Research line: {name}"))
450                .with_research_line(name)
451                .with_notes(format!("Research line created. {description}"));
452
453        match self.kb.record(record) {
454            Ok(()) => ToolCallResult::text(format!("Research line '{name}' created")),
455            Err(e) => ToolCallResult::error(format!("Failed: {e}")),
456        }
457    }
458
459    /// `generate_report`: a Markdown report for `research_line` — the
460    /// experiment table, the trajectory of its first metric, the
461    /// significant changes, and the line's trend. Markdown because the
462    /// consumer is a model (or a human pasted the output): structure
463    /// survives, no renderer needed.
464    pub fn generate_report(&self, params: &serde_json::Value) -> ToolCallResult {
465        let Some(line) = params.get("research_line").and_then(|v| v.as_str()) else {
466            return ToolCallResult::error("Missing: research_line");
467        };
468
469        let experiments = self.kb.experiments_in_line(line).unwrap_or_default();
470        if experiments.is_empty() {
471            return ToolCallResult::text(format!("# {line}\n\nNo experiments found."));
472        }
473
474        let mut report = format!("# Research Report: {line}\n\n");
475        report.push_str(&format!("**Experiments**: {}\n\n", experiments.len()));
476
477        // Summary table
478        report.push_str("## Experiments\n\n");
479        report.push_str("| ID | Hypothesis | Metrics | Notes |\n");
480        report.push_str("|---|---|---|---|\n");
481        for exp in &experiments {
482            let metrics_str: String = exp
483                .metrics
484                .iter()
485                .map(|(k, v)| format!("{k}={v:.4}"))
486                .collect::<Vec<_>>()
487                .join(", ");
488            report.push_str(&format!(
489                "| {} | {} | {} | {} |\n",
490                exp.id,
491                exp.hypothesis.as_deref().unwrap_or("-"),
492                metrics_str,
493                exp.notes.as_deref().unwrap_or("-"),
494            ));
495        }
496
497        // Trajectory
498        if let Some(first_metric) = experiments[0].metrics.keys().next() {
499            let traj = self.kb.trajectory(line, first_metric).unwrap_or_default();
500            if !traj.is_empty() {
501                report.push_str(&format!("\n## Trajectory ({first_metric})\n\n"));
502                for (id, val) in &traj {
503                    report.push_str(&format!("- {id}: {val:.4}\n"));
504                }
505            }
506
507            // Change points
508            let cps = self
509                .kb
510                .change_points(line, first_metric, 0.05)
511                .unwrap_or_default();
512            if !cps.is_empty() {
513                report.push_str("\n## Significant Changes\n\n");
514                for cp in &cps {
515                    report.push_str(&format!("- **{}**: {}\n", cp.experiment_id, cp.description));
516                }
517            }
518        }
519
520        // Research lines status
521        if let Ok(lines) = self.kb.research_lines()
522            && let Some(this_line) = lines.iter().find(|l| l.name == line)
523        {
524            report.push_str(&format!(
525                "\n## Status\n\n- **Trend**: {}\n- **Best metric**: {:?}\n",
526                this_line.trend, this_line.best_metric_value
527            ));
528        }
529
530        ToolCallResult::text(report)
531    }
532
533    // ═══════════════════════════════════════
534    // Helpers
535    // ═══════════════════════════════════════
536
537    fn resolve_path(&self, path: &str) -> PathBuf {
538        let p = PathBuf::from(path);
539        if p.is_absolute() {
540            p
541        } else {
542            self.project_dir.join(p)
543        }
544    }
545}
546
547/// Find Python and Rust filter files in a directory.
548pub(crate) fn find_filter_files(dir: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
549    let mut files = Vec::new();
550    if !dir.exists() {
551        return Ok(files);
552    }
553    for entry in std::fs::read_dir(dir)? {
554        let entry = entry?;
555        let path = entry.path();
556        if path.is_file()
557            && let Some(ext) = path.extension()
558            && (ext == "py" || ext == "rs")
559        {
560            files.push(path);
561        } else if path.is_dir()
562            && !path
563                .file_name()
564                .is_some_and(|n| n.to_string_lossy().starts_with('.'))
565        {
566            files.extend(find_filter_files(&path)?);
567        }
568    }
569    files.sort();
570    Ok(files)
571}