diff --git a/Engine/apps/agent-backend/src/main.rs b/Engine/apps/agent-backend/src/main.rs index 9d1a9f7b..6bceda4d 100644 --- a/Engine/apps/agent-backend/src/main.rs +++ b/Engine/apps/agent-backend/src/main.rs @@ -24,23 +24,62 @@ fn main() { std::process::exit(2); } }; - let Some(root) = find_project_root(&project_root) else { - eprintln!("The supplied folder is not a readable Rustic project"); - std::process::exit(2); - }; + // Global MCP registrations have no --project argument, and desktop clients + // may launch us outside a game. Project discovery must not gate the handshake. + let mut session = WorkspaceSession::new(project_root); let stdin = io::stdin(); let mut stdout = io::stdout().lock(); for line in stdin.lock().lines().map_while(Result::ok) { let Ok(request) = serde_json::from_str::(&line) else { continue; }; - if let Some(response) = handle(&root, &request) { + if let Some(response) = handle(&mut session, &request) { let _ = writeln!(stdout, "{response}"); let _ = stdout.flush(); } } } +struct WorkspaceSession { + start: PathBuf, + root: Option, +} + +impl WorkspaceSession { + fn new(start: PathBuf) -> Self { + Self { + root: find_project_root(&start), + start, + } + } + + fn project_for_tool(&mut self, name: &str, args: &Value) -> Result<&Path, String> { + if name == "workspace_info" && args.get("project_root").is_some() { + let supplied = Path::new(required_arg(args, "project_root")?); + if !supplied.is_absolute() { + return Err("project_root must be an absolute directory path".into()); + } + if !supplied.is_dir() { + return Err("project_root must name a readable game directory".into()); + } + // Keep an existing selection if a replacement is invalid. + let root = find_project_root(supplied).ok_or_else(|| { + format!( + "No readable Rustic project was found at {}. Select a game directory containing project.engine.", + supplied.display() + ) + })?; + self.root = Some(root); + } + if self.root.is_none() { + self.root = find_project_root(&self.start); + } + self.root.as_deref().ok_or_else(|| { + "No Rustic project is selected. Call workspace_info with project_root set to an absolute game directory containing project.engine, or configure the MCP server with --project .".into() + }) + } +} + fn find_project_root(start: &Path) -> Option { let canonical = start.canonicalize().ok()?; canonical @@ -49,7 +88,7 @@ fn find_project_root(start: &Path) -> Option { .map(Path::to_path_buf) } -fn handle(root: &Path, request: &Value) -> Option { +fn handle(session: &mut WorkspaceSession, request: &Value) -> Option { let id = request.get("id")?.clone(); let method = request .get("method") @@ -59,7 +98,8 @@ fn handle(root: &Path, request: &Value) -> Option { "initialize" => json!({ "protocolVersion": "2025-06-18", "capabilities": {"tools": {"listChanged": false}}, - "serverInfo": {"name": "rustic-workspace", "version": env!("CARGO_PKG_VERSION")} + "serverInfo": {"name": "rustic-workspace", "version": env!("CARGO_PKG_VERSION")}, + "instructions": "Call workspace_info first. If no game is selected, supply its absolute directory in project_root. Read project instructions with read_agent_file before editing game content." }), "tools/list" => json!({"tools": tools()}), "tools/call" => { @@ -72,7 +112,10 @@ fn handle(root: &Path, request: &Value) -> Option { .get("arguments") .cloned() .unwrap_or_else(|| json!({})); - match call_tool(root, name, &args) { + match session + .project_for_tool(name, &args) + .and_then(|root| call_tool(root, name, &args)) + { Ok(value) => json!({"content": [{"type": "text", "text": value.to_string()}]}), Err(error) => { json!({"isError": true, "content": [{"type": "text", "text": error}]}) @@ -91,7 +134,7 @@ fn handle(root: &Path, request: &Value) -> Option { fn tools() -> Value { json!([ - {"name":"workspace_info","description":"Return the canonical Rustic project root, project metadata, and repository guidance.","inputSchema":{"type":"object","properties":{}}}, + {"name":"workspace_info","description":"Return the canonical Rustic project root, project metadata, and repository guidance. Supply project_root to select a game when the MCP client starts outside a project; selection persists for this connection.","inputSchema":{"type":"object","properties":{"project_root":{"type":"string","description":"Absolute game directory containing project.engine, or a directory below it."}}}}, {"name":"list_agent_files","description":"List agent information in Rustic editor app data. Scope defaults to project; user selects shared integrations.","inputSchema":{"type":"object","properties":{"scope":{"type":"string","enum":["project","user"]}}}}, {"name":"read_agent_file","description":"Read a UTF-8 agent file relative to the app-data agent directory (maximum 2 MiB). Scope defaults to project.","inputSchema":{"type":"object","required":["path"],"properties":{"path":{"type":"string"},"scope":{"type":"string","enum":["project","user"]}}}}, {"name":"write_agent_file","description":"Atomically edit app-data agent information; edits persist across editor restarts. Scope defaults to project.","inputSchema":{"type":"object","required":["path","content"],"properties":{"path":{"type":"string"},"content":{"type":"string"},"scope":{"type":"string","enum":["project","user"]}}}}, @@ -351,6 +394,93 @@ fn required_arg<'a>(args: &'a Value, name: &str) -> Result<&'a str, String> { #[cfg(test)] mod tests { use super::*; + + #[test] + fn handshake_and_tool_discovery_do_not_require_a_project() { + let temp = tempfile::tempdir().unwrap(); + let mut session = WorkspaceSession::new(temp.path().to_path_buf()); + for method in ["initialize", "tools/list", "ping"] { + let response = handle( + &mut session, + &json!({"jsonrpc":"2.0","id":1,"method":method}), + ) + .unwrap(); + assert!(response.get("result").is_some(), "{response}"); + } + assert!(session.root.is_none()); + let response = handle( + &mut session, + &json!({"id":2,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"project.engine"}}}), + ) + .unwrap(); + assert_eq!(response["result"]["isError"], true); + assert!( + response["result"]["content"][0]["text"] + .as_str() + .unwrap() + .contains("workspace_info") + ); + assert!(handle(&mut session, &json!({"method":"notifications/initialized"})).is_none()); + } + + #[test] + fn project_selection_persists_and_invalid_selection_keeps_the_previous_game() { + let temp = tempfile::tempdir().unwrap(); + let project = Project::create( + temp.path().join("game"), + "Selected game", + engine_project::ProjectTemplate::Blank, + ) + .unwrap(); + let root = project.root().canonicalize().unwrap(); + let mut session = WorkspaceSession::new(temp.path().to_path_buf()); + assert_eq!( + session + .project_for_tool("workspace_info", &json!({"project_root": root})) + .unwrap(), + root + ); + assert_eq!( + session.project_for_tool("read_file", &json!({})).unwrap(), + root + ); + for supplied in [ + json!(temp.path()), + json!(temp.path().join("missing")), + json!(root.join("project.engine")), + json!("relative/game"), + json!(null), + json!(42), + ] { + assert!( + session + .project_for_tool("workspace_info", &json!({"project_root": supplied})) + .is_err() + ); + assert_eq!(session.root.as_ref(), Some(&root)); + } + let response = handle( + &mut session, + &json!({"id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"project.engine"}}}), + ) + .unwrap(); + assert_eq!(response["result"]["content"][0]["type"], "text"); + assert!(response["result"].get("isError").is_none()); + } + + #[test] + fn discovery_from_a_game_subdirectory_still_selects_that_game() { + let temp = tempfile::tempdir().unwrap(); + let project = Project::create( + temp.path().join("game"), + "Discovered game", + engine_project::ProjectTemplate::Blank, + ) + .unwrap(); + let session = WorkspaceSession::new(project.root().join("scripts")); + assert_eq!(session.root, Some(project.root().canonicalize().unwrap())); + } + #[test] fn agent_tools_read_write_list_and_reject_escapes() { let temp = tempfile::tempdir().unwrap(); diff --git a/Engine/apps/agent-backend/tests/stdio_startup.rs b/Engine/apps/agent-backend/tests/stdio_startup.rs new file mode 100644 index 00000000..7cf4182f --- /dev/null +++ b/Engine/apps/agent-backend/tests/stdio_startup.rs @@ -0,0 +1,94 @@ +use engine_project::{Project, ProjectTemplate}; +use serde_json::{Value, json}; +use std::io::Write as _; +use std::path::Path; +use std::process::{Command, Stdio}; + +fn exchange(directory: &Path, args: &[&str], requests: &[Value]) -> Vec { + let mut child = Command::new(env!("CARGO_BIN_EXE_rustic-agent-backend")) + .args(args) + .current_dir(directory) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + for request in requests { + writeln!(input, "{request}").unwrap(); + } + drop(input); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "backend exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout) + .unwrap() + .lines() + .map(|line| { + serde_json::from_str(line).expect("stdout must contain only JSON-RPC responses") + }) + .collect() +} + +#[test] +fn server_starts_outside_a_game_and_responds_to_initialize_and_tools_list() { + let temp = tempfile::tempdir().unwrap(); + let requests = [ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"startup-regression","version":"1"}}}), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}), + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"workspace_info","arguments":{}}}), + json!({"jsonrpc":"2.0","id":4,"method":"ping"}), + ]; + // Both the globally registered launch and a stale explicit --project path + // must keep the connection alive, with project selection errors at tool time. + for args in [vec![], vec!["--project", "missing-game"]] { + let responses = exchange(temp.path(), &args, &requests); + assert_eq!(responses.len(), 4); + assert_eq!( + responses[0]["result"]["serverInfo"]["name"], + "rustic-workspace" + ); + assert!( + responses[1]["result"]["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "workspace_info" + && tool["inputSchema"]["properties"]["project_root"]["type"] == "string") + ); + assert_eq!(responses[2]["result"]["isError"], true); + assert_eq!(responses[3]["result"], json!({})); + } +} + +#[test] +fn project_can_be_selected_after_connecting_and_file_tools_use_it() { + let temp = tempfile::tempdir().unwrap(); + let project = + Project::create(temp.path().join("game"), "MCP game", ProjectTemplate::Blank).unwrap(); + let root = project.root().canonicalize().unwrap(); + let responses = exchange( + temp.path(), + &[], + &[ + json!({"jsonrpc":"2.0","id":1,"method":"initialize"}), + json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"workspace_info","arguments":{"project_root":root}}}), + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"read_file","arguments":{"path":"project.engine"}}}), + ], + ); + assert_eq!(responses.len(), 3); + let workspace: Value = serde_json::from_str( + responses[1]["result"]["content"][0]["text"] + .as_str() + .unwrap(), + ) + .unwrap(); + assert_eq!(workspace["project_root"], json!(root)); + assert_eq!(workspace["name"], "MCP game"); + assert!(responses[2]["result"].get("isError").is_none()); +} diff --git a/Engine/docs/AGENT_INTEGRATION.md b/Engine/docs/AGENT_INTEGRATION.md index ff07d11c..d42efb33 100644 --- a/Engine/docs/AGENT_INTEGRATION.md +++ b/Engine/docs/AGENT_INTEGRATION.md @@ -10,17 +10,29 @@ generated programming workspace still belong to the game project. 1. Install Rustic and open your game project in the Rustic editor once. Opening the project generates its agent information and registers the `rustic-workspace` MCP server in your user Codex and Claude configuration. -2. Start your agent from the game directory (or a directory below it), and reload - its MCP connections if it was already running. The backend discovers the - containing `project.engine` file. You can also explicitly configure its - command as the installed `rustic-agent-backend.exe` and arguments as - `["--project", "C:/Games/MyGame"]`. +2. Reload the agent's MCP connections if it was already running. Starting + from the game directory (or a directory below it) automatically discovers + the containing `project.engine` file. The backend also starts from other + directories: the MCP handshake and tool list do not require a game project. + To bind a game from the Codex desktop app or an engine checkout, call + `workspace_info` with `{"project_root":"C:/Games/MyGame"}`. Use an absolute + directory path; selection is retained for this MCP session. Alternatively, + configure the installed `rustic-agent-backend.exe` with arguments + `["--project", "C:/Games/MyGame"]` and reload the connection. 3. Invoke the `rustic-workspace` skill, or `/rustic-workspace` in Claude, and ask it to inspect your project. Call `workspace_info` first; its `project_root` identifies the game and `agent_directory` identifies its stored information. 4. Read instructions through `read_agent_file` before editing game content. Use `scene_summary` with your scene's relative path before changing a scene. + +The stdio MCP server runs on the same computer as the agent client and the game +files. A local Rustic editor registration is not available to a Codex cloud +chat. Use a local Codex chat with the registered MCP connection to control that +game. Opening a game locally does not expose the backend to a remote executor, +and a missing `@rustic-workspace` entry in a cloud chat does not establish that +the local server crashed. + Only small discovery skills, a Claude command, and client registration settings remain in the user profile so agent applications can find the integration. The full skills and plugin live in Rustic app data. No gameplay script attachment or @@ -97,8 +109,17 @@ Empty integration directories are removed after migration. - If the tools are missing, reopen the editor and reload your agent's MCP server. Confirm the installed backend executable exists. You can rerun `rustic-agent-backend.exe --install-user-integrations` to register it again. -- “The supplied folder is not a readable Rustic project” means the agent started - outside a game project. Set `--project` to the directory containing `project.engine`. +- “No Rustic project is selected” is a tool error, not a failed MCP startup. + Call `workspace_info` with `{"project_root":"C:/Games/MyGame"}` using an + absolute path to a readable game directory containing `project.engine`. + Later file and scene tools use that selected game. An invalid selection + reports a tool error and keeps the previous selection; it does not close the + server. You can also set `--project` in the MCP registration and reload it. +- An older backend may exit with “The supplied folder is not a readable Rustic + project” before connecting when Codex starts outside a game. Point that + registration at the game with `--project`, or install the updated backend + and reload the connection. Opening the editor alone does not replace an + already running MCP process. - If a file is missing, call `list_agent_files` and check the scope and relative path. Ordinary `read_file` and `write_file` address game content; agent metadata uses `read_agent_file` and `write_agent_file`.