Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ No encryption on modern saves (format 2002+, post-Frontiers). The only crypto is

### MCP Server

The REPL includes a built-in MCP server for AI co-exploration. It starts automatically on `http://127.0.0.1:5099` and shares the same live model as the REPL. The server watches the selected save file and updates its in-memory location as the player moves.
The REPL includes a built-in MCP server for AI co-exploration. It starts automatically on `http://127.0.0.1:5099` and shares the same in-memory model as the REPL. When watching is enabled, the server follows changes to the selected save file.

For headless operation (e.g., Claude Desktop integration):

Expand All @@ -297,10 +297,29 @@ nms-copilot --headless --save /path/to/save.hg # stdio t
nms-copilot --headless --http 127.0.0.1:5099 --save /path/to/save.hg # HTTP transport
```

For stdio transport, stdout carries MCP protocol messages and diagnostics go to stderr. HTTP transport serves MCP over the configured address.

The MCP server exposes all query capabilities as tools — your AI copilot can search planets, plan routes, convert coordinates, and track your position as you play. The save file is read only; NMS Copilot does not edit it.

For the current location, call the `where_am_i` tool or read `nms://player/location`. A client can subscribe to that resource with `resources/subscribe`; when the watched save changes, the server sends `notifications/resources/updated` for the location URI. Read the resource again to fetch the current location. This describes movement observed after the watcher starts; full startup/cache composition, unknown-system resolution, and watch configuration policy are outside this behavior description.
For the current location, call the `where_am_i` tool or read `nms://player/location`. A client can subscribe to that resource with `resources/subscribe`; when an enabled watcher ingests a save change, the server sends `notifications/resources/updated` for the location URI. Read the resource again to fetch the current location.

Save watching is controlled by `~/.nms-copilot/config.toml` and takes effect at startup:

```toml
[watch]
enabled = true # default: true
debounce_ms = 500 # default: 500
```

Set `enabled = false` to disable later save-file ingestion:

```toml
[watch]
enabled = false
debounce_ms = 500
```

The setting applies to headless stdio and HTTP servers and to both normal-mode file watchers. The selected save still loads at startup and MCP serves that in-memory model when watching is disabled. Full startup-cache/live-update behavior, unknown-system resolution, and composition when both normal-mode producers are active remain planned follow-up work.

---

Expand Down
98 changes: 63 additions & 35 deletions crates/nms-copilot/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use nms_copilot::session::SessionState;
use nms_copilot::watch::drain_watch_events;
use nms_copilot::{commands, dispatch, paths};
use nms_graph::GalaxyModel;
use nms_watch::{WatchConfig, WatchHandle, start_watching};
use nms_watch::{WatchConfig, start_watching};

const DEFAULT_MCP_HTTP_ADDR: &str = "127.0.0.1:5099";

Expand Down Expand Up @@ -138,6 +138,7 @@ fn main() {

let mut model = model;
model.ensure_player_system();
let watch_config = configured_watcher(&config, save_path.as_deref());

// ── Headless mode: MCP server only ──────────────────────────
if cli.headless {
Expand All @@ -149,7 +150,7 @@ fn main() {
model.blocking_read().planet_count(),
model.blocking_read().bases.len(),
);
mcp::run_headless(model, transport, save_path);
mcp::run_headless_with_watch_config(model, transport, watch_config);
return;
}

Expand All @@ -170,20 +171,19 @@ fn main() {
);

// Start file watcher (optional)
let watch_handle = if config.watch_enabled() {
match start_watcher(&config, save_path.clone()) {
Ok(handle) => {
println!("Watching save file for live updates.\n");
Some(handle)
}
Err(e) => {
eprintln!("Warning: could not start file watcher: {e}\n");
None
}
let watch_handle = match watch_config.clone().map(start_watching).transpose() {
Ok(Some(handle)) => {
println!("Watching save file for live updates.\n");
Some(handle)
}
Ok(None) => {
println!();
None
}
Err(e) => {
eprintln!("Warning: could not start file watcher: {e}\n");
None
}
} else {
println!();
None
};

let cache_for_watcher = if no_cache {
Expand All @@ -197,10 +197,10 @@ fn main() {

// Start MCP HTTP server on background thread (shares the model)
let mcp_addr = mcp_http_addr(&cli, &config);
mcp::spawn_mcp_background(
mcp::spawn_mcp_background_with_watch_config(
Arc::clone(&model),
mcp::Transport::Http(mcp_addr),
save_path,
watch_config,
);
let mcp_base = format!("http://{mcp_addr}");
eprintln!("MCP server listening on {mcp_base}");
Expand Down Expand Up @@ -360,26 +360,15 @@ fn load_model(
Ok((result.model, result.was_cached, result.save_version))
}

fn start_watcher(
config: &Config,
save_path: Option<PathBuf>,
) -> Result<WatchHandle, Box<dyn std::error::Error>> {
let path = match save_path {
Some(p) => p,
None => match config.save_path() {
Some(p) => p,
None => nms_save::locate::find_most_recent_save()?
.path()
.to_path_buf(),
},
};
fn configured_watcher(config: &Config, save_path: Option<&Path>) -> Option<WatchConfig> {
if !config.watch_enabled() {
return None;
}

let watch_config = WatchConfig {
save_path: path,
Some(WatchConfig {
save_path: save_path?.to_path_buf(),
debounce: config.watch_debounce(),
};

Ok(start_watching(watch_config)?)
})
}

/// Parse MCP transport for headless mode.
Expand Down Expand Up @@ -837,6 +826,45 @@ impl std::error::Error for SmokeError {}
mod tests {
use super::*;

#[test]
fn test_configured_watcher_missing_path_returns_none() {
assert!(configured_watcher(&Config::default(), None).is_none());
}

#[test]
fn test_configured_watcher_disabled_ignores_present_path_without_io() {
let mut config = Config::default();
config.watch.enabled = false;
let path = Path::new("a/path/that/does/not/exist/save.hg");

assert!(configured_watcher(&config, Some(path)).is_none());
}

#[test]
fn test_configured_watcher_default_preserves_exact_path_and_debounce() {
let config = Config::default();
let path = Path::new("relative/../save.hg");

let watch = configured_watcher(&config, Some(path)).expect("default watch enabled");

assert_eq!(watch.save_path, path);
assert_eq!(watch.debounce, Duration::from_millis(500));
}

#[test]
fn test_configured_watcher_nondefault_and_zero_debounce_are_literal() {
let path = Path::new("relative/../save.hg");
for (debounce_ms, expected) in [(137, 137), (0, 0)] {
let mut config = Config::default();
config.watch.debounce_ms = debounce_ms;

let watch = configured_watcher(&config, Some(path)).expect("watch enabled");

assert_eq!(watch.save_path, path);
assert_eq!(watch.debounce, Duration::from_millis(expected));
}
}

#[test]
fn test_cli_help_parses_before_startup() {
let err = Cli::try_parse_from(["nms-copilot", "--help"]).unwrap_err();
Expand Down
82 changes: 67 additions & 15 deletions crates/nms-copilot/src/mcp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ pub enum Transport {
/// the MCP server runs concurrently with the REPL. Stdio transport should only
/// be used in headless mode.
///
/// Optionally starts a file watcher that applies deltas to the shared model.
/// Starts a default-debounce watcher when `save_path` is `Some`.
///
/// This compatibility wrapper uses a 500ms debounce; use
/// [`spawn_mcp_background_with_watch_config`] to supply explicit settings.
///
/// For HTTP transport, blocks the caller until the server is listening
/// (TCP bind succeeded) so the REPL doesn't start before the MCP endpoint
Expand All @@ -47,6 +50,19 @@ pub fn spawn_mcp_background(
model: Arc<RwLock<GalaxyModel>>,
transport: Transport,
save_path: Option<std::path::PathBuf>,
) {
spawn_mcp_background_with_watch_config(model, transport, default_watch_config(save_path));
}

/// Start the MCP server on a background thread with an explicit watcher setting.
///
/// `Some(config)` is forwarded unchanged to the save watcher and its owned
/// handle remains alive while the server runs. `None` disables file watching
/// while the server continues to use the supplied in-memory model.
pub fn spawn_mcp_background_with_watch_config(
model: Arc<RwLock<GalaxyModel>>,
transport: Transport,
watch_config: Option<nms_watch::WatchConfig>,
) {
let mcp_service = ServiceHandle::new("mcp-http");
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel::<Result<(), String>>(1);
Expand All @@ -55,7 +71,7 @@ pub fn spawn_mcp_background(
thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
rt.block_on(async {
let watcher = start_watcher(save_path.as_deref());
let watcher = start_watcher(watch_config);

if let Err(e) = run_mcp_server(
model,
Expand Down Expand Up @@ -85,34 +101,52 @@ pub fn spawn_mcp_background(
}
}

/// Run the MCP server in headless mode (blocking, on the current thread).
/// Run the MCP server in headless mode with a default-debounce watcher.
///
/// Creates a tokio runtime and blocks until the server shuts down.
/// This compatibility wrapper uses a 500ms debounce when `save_path` is
/// `Some`; use [`run_headless_with_watch_config`] for explicit settings.
pub fn run_headless(
model: Arc<RwLock<GalaxyModel>>,
transport: Transport,
save_path: Option<std::path::PathBuf>,
) {
run_headless_with_watch_config(model, transport, default_watch_config(save_path));
}

/// Run the MCP server in headless mode with an explicit watcher setting.
///
/// `Some(config)` is forwarded unchanged to the save watcher and its owned
/// handle remains alive while the server runs. `None` disables file watching
/// while the server continues to use the supplied in-memory model.
pub fn run_headless_with_watch_config(
model: Arc<RwLock<GalaxyModel>>,
transport: Transport,
watch_config: Option<nms_watch::WatchConfig>,
) {
let mcp_service = ServiceHandle::new("mcp");
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
rt.block_on(async {
let watcher = start_watcher(save_path.as_deref());
let watcher = start_watcher(watch_config);

if let Err(e) = run_mcp_server(model, transport, watcher, None, mcp_service).await {
eprintln!("MCP server error: {e}");
}
});
}

/// Start the file watcher if a save path is provided.
fn default_watch_config(save_path: Option<std::path::PathBuf>) -> Option<nms_watch::WatchConfig> {
save_path.map(|save_path| nms_watch::WatchConfig {
save_path,
..Default::default()
})
}

/// Start the watcher with the caller's exact configuration, if enabled.
///
/// Returns the owned watcher handle if watching started successfully.
fn start_watcher(save_path: Option<&std::path::Path>) -> Option<nms_watch::WatchHandle> {
let path = save_path?;
let watch_config = nms_watch::WatchConfig {
save_path: path.to_path_buf(),
..Default::default()
};
fn start_watcher(watch_config: Option<nms_watch::WatchConfig>) -> Option<nms_watch::WatchHandle> {
let watch_config = watch_config?;
let path = watch_config.save_path.clone();
match nms_watch::start_watching(watch_config) {
Ok(handle) => {
log::info!("File watcher started for {}", path.display());
Expand Down Expand Up @@ -539,6 +573,13 @@ mod watcher_lifecycle_tests {
use super::*;
use std::time::{Duration, Instant};

fn watch_config(save_path: std::path::PathBuf) -> nms_watch::WatchConfig {
nms_watch::WatchConfig {
save_path,
debounce: Duration::from_millis(500),
}
}

fn save_bytes(reality_index: u8, voxel_x: i16) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"Version": 4720,
Expand Down Expand Up @@ -600,11 +641,22 @@ mod watcher_lifecycle_tests {

let dir = tempfile::tempdir().expect("temporary directory");
let missing = dir.path().join("missing.json");
assert!(start_watcher(Some(&missing)).is_none());
assert!(start_watcher(Some(watch_config(missing))).is_none());

let malformed = dir.path().join("malformed.json");
std::fs::write(&malformed, b"not a save").expect("write malformed save");
assert!(start_watcher(Some(&malformed)).is_none());
assert!(start_watcher(Some(watch_config(malformed))).is_none());
}

#[test]
fn test_legacy_watch_config_preserves_path_and_default_debounce() {
assert!(default_watch_config(None).is_none());
let path = std::path::PathBuf::from("relative/../save.hg");

let config = default_watch_config(Some(path.clone())).expect("path enables default watch");

assert_eq!(config.save_path, path);
assert_eq!(config.debounce, Duration::from_millis(500));
}

#[test]
Expand All @@ -613,7 +665,7 @@ mod watcher_lifecycle_tests {
let path = dir.path().join("save.json");
std::fs::write(&path, save_bytes(0, 0)).expect("write initial save");

let mut owner = start_watcher(Some(&path)).expect("watcher starts");
let mut owner = start_watcher(Some(watch_config(path.clone()))).expect("watcher starts");
let receiver = std::mem::replace(&mut owner.receiver, std::sync::mpsc::channel().1);
std::fs::write(&path, save_bytes(9, 0)).expect("write later save contents");

Expand Down
Loading
Loading