diff --git a/Cargo.lock b/Cargo.lock index e6430aa2..d97cd761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,6 +239,7 @@ dependencies = [ "rmp-serde", "roblox_install", "rojo-insta-ext", + "schemars", "semver 1.0.27", "serde", "serde_bytes", @@ -966,6 +967,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -3747,6 +3754,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -4052,6 +4079,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4148,6 +4200,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2 1.0.106", + "quote 1.0.44", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/Cargo.toml b/Cargo.toml index 378a35c0..b5b6a9e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ tempfile = "3.26.0" ritz = "0.1.0" roblox_install = "1.0.0" serde = { version = "1.0.228", features = ["derive", "rc"] } +schemars = "1" serde_json = "1.0.149" json5 = "1.3.1" lexical-write-float = "1.0.6" diff --git a/TODO.txt b/TODO.txt index 126e1f31..291e11b2 100644 --- a/TODO.txt +++ b/TODO.txt @@ -370,6 +370,7 @@ can we allow the terminal to stay so i can like run some git commands myself wit - Listen to MessageOut to get the log info we are wanting. - Is this actually the one we want? I might be confused, check the docs, this looks new. * The goal with these trace logging is so that when others are using this tool, we can actually debug real issues by having them send me their logs. +* It might be worth actually having a log entry per-sync? [sync] right now sync is running REALLY slow diff --git a/src/web/mcp.rs b/src/web/mcp.rs index 7fee5560..c1142d9f 100644 --- a/src/web/mcp.rs +++ b/src/web/mcp.rs @@ -6,6 +6,7 @@ use std::sync::{ use bytes::Bytes; use http_body_util::{BodyExt, Full}; use hyper::{body::Incoming, header::CONTENT_TYPE, Method, Request, Response, StatusCode}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -26,7 +27,7 @@ pub struct PluginConfig { } /// Agent-specified auto-accept directive for a single instance change. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct SyncOverride { pub id: String, @@ -94,6 +95,101 @@ pub struct PluginToolResult { pub response: Option, } +// --------------------------------------------------------------------------- +// Tool argument schemas (schemars-derived, used only for inputSchema generation) +// --------------------------------------------------------------------------- + +#[allow(dead_code)] +#[derive(JsonSchema)] +struct AtlasSyncArgs { + /// Sync mode. 'standard' (default): auto-accept if all changes are + /// git-resolved, otherwise show Studio UI. 'manual': always show + /// Studio UI. 'fastfail': fail immediately if any changes are + /// unresolved. 'dryrun': return what would change without applying. + mode: Option, + /// Auto-accept directives for specific instance changes, matched + /// by id from a previous sync response. + overrides: Option>, +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(rename_all = "snake_case")] +enum SyncMode { + Standard, + Manual, + Fastfail, + Dryrun, +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(rename_all = "camelCase")] +struct GetScriptArgs { + /// Server Ref (32-char hex) from a previous atlas_sync response. + /// Preferred over fsPath. + id: Option, + /// Atlas filesystem path relative to project root + /// (e.g. 'src/server/MyScript.server.luau'). Resolved server-side + /// to an instance id. + fs_path: Option, + /// If true, read unsaved editor content instead of the saved Source + /// property. Default: false. + from_draft: Option, +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +struct RunCodeArgs { + /// Luau code to execute in Roblox Studio. + command: String, +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +struct InsertModelArgs { + /// Search query for the model on the Roblox Creator Store. + query: String, +} + +#[derive(JsonSchema)] +struct NoArgs {} + +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(rename_all = "snake_case")] +enum StartStopPlayMode { + StartPlay, + RunServer, + Stop, +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +struct StartStopPlayArgs { + /// Play mode action. + mode: StartStopPlayMode, +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +#[schemars(rename_all = "snake_case")] +enum PlayTestMode { + StartPlay, + RunServer, +} + +#[allow(dead_code)] +#[derive(JsonSchema)] +struct RunScriptInPlayModeArgs { + /// Luau code to run inside a play session. + code: String, + /// Timeout in seconds. Defaults to 100. + timeout: Option, + /// Play mode to start. + mode: PlayTestMode, +} + /// Result for get_script, deserialized from the plugin's Value response. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -294,155 +390,50 @@ fn handle_initialize(id: Option) -> Response> { json_response(&resp, StatusCode::OK) } -fn handle_tools_list(id: Option) -> Response> { - let result = serde_json::json!({ - "tools": [ - { - "name": "atlas_sync", - "description": include_str!("mcp_docs/atlas_sync.md"), - "inputSchema": { - "type": "object", - "properties": { - "mode": { - "type": "string", - "enum": ["standard", "manual", "fastfail", "dryrun"], - "description": "Sync mode. 'standard' (default): auto-accept if all changes are git-resolved, otherwise show Studio UI. 'manual': always show Studio UI. 'fastfail': fail immediately if any changes are unresolved. 'dryrun': return what would change without applying." - }, - "overrides": { - "type": "array", - "description": "Auto-accept directives for specific instance changes, matched by id from a previous sync response.", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Server Ref (32-char hex) from a previous sync response." - }, - "direction": { - "type": "string", - "enum": ["push", "pull"], - "description": "Direction to accept: 'push' (Atlas to Studio) or 'pull' (Studio to Atlas)." - }, - "studioHash": { - "type": "string", - "description": "For scripts: SHA1 of git blob of current Studio Source. Override is rejected if hash doesn't match (concurrent edit detected)." - }, - "expectedProperties": { - "type": "object", - "description": "Map of property name to expected current Studio value (RbxDom encoded). Override is rejected if any value doesn't match." - } - }, - "required": ["id", "direction"] - } - } - } - } - }, - { - "name": "get_script", - "description": include_str!("mcp_docs/get_script.md"), - "inputSchema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Server Ref (32-char hex) from a previous atlas_sync response. Preferred over fsPath." - }, - "fsPath": { - "type": "string", - "description": "Atlas filesystem path relative to project root (e.g. 'src/server/MyScript.server.luau'). Resolved server-side to an instance id." - }, - "fromDraft": { - "type": "boolean", - "description": "If true, read unsaved editor content instead of the saved Source property. Default: false." - } - } - } - }, - { - "name": "run_code", - "description": include_str!("mcp_docs/run_code.md"), - "inputSchema": { - "type": "object", - "properties": { - "command": { - "type": "string", - "description": "Luau code to execute in Roblox Studio." - } - }, - "required": ["command"] - } - }, - { - "name": "insert_model", - "description": include_str!("mcp_docs/insert_model.md"), - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query for the model on the Roblox Creator Store." - } - }, - "required": ["query"] - } - }, - { - "name": "get_console_output", - "description": include_str!("mcp_docs/get_console_output.md"), - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "get_studio_mode", - "description": include_str!("mcp_docs/get_studio_mode.md"), - "inputSchema": { - "type": "object", - "properties": {} - } - }, - { - "name": "start_stop_play", - "description": include_str!("mcp_docs/start_stop_play.md"), - "inputSchema": { - "type": "object", - "properties": { - "mode": { - "type": "string", - "enum": ["start_play", "run_server", "stop"], - "description": "Play mode action." - } - }, - "required": ["mode"] - } - }, - { - "name": "run_script_in_play_mode", - "description": include_str!("mcp_docs/run_script_in_play_mode.md"), - "inputSchema": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Luau code to run inside a play session." - }, - "timeout": { - "type": "integer", - "description": "Timeout in seconds. Defaults to 100." - }, - "mode": { - "type": "string", - "enum": ["start_play", "run_server"], - "description": "Play mode to start." - } - }, - "required": ["code", "mode"] - } - } - ] +fn tool_def(name: &str, description: &str) -> Value { + let settings = schemars::generate::SchemaSettings::draft07().with(|s| { + s.meta_schema = None; + s.inline_subschemas = true; }); + let schema = settings.into_generator().into_root_schema_for::(); + let mut schema_value = serde_json::to_value(schema).unwrap_or_default(); + if let Some(obj) = schema_value.as_object_mut() { + obj.remove("$schema"); + obj.remove("title"); + obj.remove("definitions"); + } + serde_json::json!({ + "name": name, + "description": description, + "inputSchema": schema_value, + }) +} + +fn handle_tools_list(id: Option) -> Response> { + let tools = vec![ + tool_def::("atlas_sync", include_str!("mcp_docs/atlas_sync.md")), + tool_def::("get_script", include_str!("mcp_docs/get_script.md")), + tool_def::("run_code", include_str!("mcp_docs/run_code.md")), + tool_def::("insert_model", include_str!("mcp_docs/insert_model.md")), + tool_def::( + "get_console_output", + include_str!("mcp_docs/get_console_output.md"), + ), + tool_def::( + "get_studio_mode", + include_str!("mcp_docs/get_studio_mode.md"), + ), + tool_def::( + "start_stop_play", + include_str!("mcp_docs/start_stop_play.md"), + ), + tool_def::( + "run_script_in_play_mode", + include_str!("mcp_docs/run_script_in_play_mode.md"), + ), + ]; + + let result = serde_json::json!({ "tools": tools }); let resp = JsonRpcResponse::success(id, result); json_response(&resp, StatusCode::OK) } @@ -2398,7 +2389,12 @@ mod tests { .unwrap(); let values: Vec<&str> = mode_enum.iter().map(|v| v.as_str().unwrap()).collect(); assert_eq!(values, vec!["start_play", "run_server"]); - assert!(tool["inputSchema"]["properties"]["timeout"]["type"] == "integer"); + let timeout = &tool["inputSchema"]["properties"]["timeout"]; + let has_integer = timeout["type"] == "integer" + || timeout["type"] + .as_array() + .map_or(false, |arr| arr.iter().any(|v| v == "integer")); + assert!(has_integer, "timeout should accept integer: {timeout}"); } } }