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
148 changes: 139 additions & 9 deletions Engine/apps/agent-backend/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Value>(&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<PathBuf>,
}

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 <game-directory>.".into()
})
}
}

fn find_project_root(start: &Path) -> Option<PathBuf> {
let canonical = start.canonicalize().ok()?;
canonical
Expand All @@ -49,7 +88,7 @@ fn find_project_root(start: &Path) -> Option<PathBuf> {
.map(Path::to_path_buf)
}

fn handle(root: &Path, request: &Value) -> Option<Value> {
fn handle(session: &mut WorkspaceSession, request: &Value) -> Option<Value> {
let id = request.get("id")?.clone();
let method = request
.get("method")
Expand All @@ -59,7 +98,8 @@ fn handle(root: &Path, request: &Value) -> Option<Value> {
"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" => {
Expand All @@ -72,7 +112,10 @@ fn handle(root: &Path, request: &Value) -> Option<Value> {
.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}]})
Expand All @@ -91,7 +134,7 @@ fn handle(root: &Path, request: &Value) -> Option<Value> {

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"]}}}},
Expand Down Expand Up @@ -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();
Expand Down
94 changes: 94 additions & 0 deletions Engine/apps/agent-backend/tests/stdio_startup.rs
Original file line number Diff line number Diff line change
@@ -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<Value> {
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());
}
35 changes: 28 additions & 7 deletions Engine/docs/AGENT_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
Loading