From 8b0014dfc999044d943960028e7a7eb51215e043 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Thu, 9 Apr 2026 07:17:50 +0530 Subject: [PATCH 1/3] feat(jsonrpc): add stdio-based json-rpc server with forge api integration --- Cargo.lock | 204 +- Cargo.toml | 3 + crates/forge_jsonrpc/Cargo.toml | 27 + crates/forge_jsonrpc/src/error.rs | 134 + crates/forge_jsonrpc/src/lib.rs | 10 + crates/forge_jsonrpc/src/server.rs | 2308 +++++++++++++++++ crates/forge_jsonrpc/src/test_utils.rs | 375 +++ crates/forge_jsonrpc/src/transport/mod.rs | 11 + crates/forge_jsonrpc/src/transport/stdio.rs | 129 + crates/forge_jsonrpc/src/types/mod.rs | 856 ++++++ .../forge_jsonrpc/tests/integration_tests.rs | 287 ++ crates/forge_jsonrpc/tests/unit_tests.rs | 150 ++ crates/forge_main/Cargo.toml | 8 + crates/forge_main/src/bin/forge-jsonrpc.rs | 73 + 14 files changed, 4566 insertions(+), 9 deletions(-) create mode 100644 crates/forge_jsonrpc/Cargo.toml create mode 100644 crates/forge_jsonrpc/src/error.rs create mode 100644 crates/forge_jsonrpc/src/lib.rs create mode 100644 crates/forge_jsonrpc/src/server.rs create mode 100644 crates/forge_jsonrpc/src/test_utils.rs create mode 100644 crates/forge_jsonrpc/src/transport/mod.rs create mode 100644 crates/forge_jsonrpc/src/transport/stdio.rs create mode 100644 crates/forge_jsonrpc/src/types/mod.rs create mode 100644 crates/forge_jsonrpc/tests/integration_tests.rs create mode 100644 crates/forge_jsonrpc/tests/unit_tests.rs create mode 100644 crates/forge_main/src/bin/forge-jsonrpc.rs diff --git a/Cargo.lock b/Cargo.lock index 0bbd367a95..3c72832892 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -515,7 +515,7 @@ dependencies = [ "pin-project-lite", "serde_core", "sync_wrapper 1.0.2", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -2150,6 +2150,28 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "forge_jsonrpc" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "dirs", + "forge_api", + "forge_app", + "forge_config", + "forge_domain", + "forge_stream", + "futures", + "jsonrpsee", + "schemars 0.8.22", + "serde", + "serde_json", + "tokio", + "tracing", + "url", +] + [[package]] name = "forge_main" version = "0.1.0" @@ -2174,6 +2196,7 @@ dependencies = [ "forge_domain", "forge_embed", "forge_fs", + "forge_jsonrpc", "forge_markdown_stream", "forge_select", "forge_spinner", @@ -2207,6 +2230,7 @@ dependencies = [ "tokio-stream", "toml_edit 0.25.10+spec-1.1.0", "tracing", + "tracing-subscriber", "update-informer", "url", "windows-sys 0.61.2", @@ -3256,7 +3280,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -3657,6 +3681,95 @@ dependencies = [ "serde", ] +[[package]] +name = "jsonrpsee" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e281ae70cc3b98dac15fced3366a880949e65fc66e345ce857a5682d152f3e62" +dependencies = [ + "jsonrpsee-core", + "jsonrpsee-proc-macros", + "jsonrpsee-server", + "jsonrpsee-types", + "tokio", + "tracing", +] + +[[package]] +name = "jsonrpsee-core" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348ee569eaed52926b5e740aae20863762b16596476e943c9e415a6479021622" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "jsonrpsee-types", + "parking_lot", + "rand 0.8.5", + "rustc-hash", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "jsonrpsee-proc-macros" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7398cddf5013cca4702862a2692b66c48a3bd6cf6ec681a47453c93d63cf8de5" +dependencies = [ + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jsonrpsee-server" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21429bcdda37dcf2d43b68621b994adede0e28061f816b038b0f18c70c143d51" +dependencies = [ + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "jsonrpsee-core", + "jsonrpsee-types", + "pin-project", + "route-recognizer", + "serde", + "serde_json", + "soketto", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "tower 0.4.13", + "tracing", +] + +[[package]] +name = "jsonrpsee-types" +version = "0.24.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f05e0028e55b15dbd2107163b3c744cd3bb4474f193f95d9708acbf5677e44" +dependencies = [ + "http 1.4.0", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "jsonwebtoken" version = "10.3.0" @@ -4659,6 +4772,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.10+spec-1.1.0", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -4815,7 +4937,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.37", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -4853,7 +4975,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -5195,7 +5317,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -5235,7 +5357,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-rustls 0.26.4", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -5336,6 +5458,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "route-recognizer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afab94fb28594581f62d981211a9a4d53cc8130bbcbbb89a0440d9b8e81a7746" + [[package]] name = "rust-ini" version = "0.21.3" @@ -5564,6 +5692,18 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "0.9.0" @@ -5585,11 +5725,23 @@ dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.1", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "schemars_derive" version = "1.2.1" @@ -6000,6 +6152,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "soketto" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e859df029d160cb88608f5d7df7fb4753fd20fdfb4de5644f3d8b8440841721" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures", + "http 1.4.0", + "httparse", + "log", + "rand 0.8.5", + "sha1", +] + [[package]] name = "sqlite-wasm-rs" version = "0.4.8" @@ -6630,6 +6798,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -6640,6 +6809,7 @@ checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -6784,7 +6954,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tokio-stream", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", "tracing", @@ -6830,6 +7000,21 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -6862,7 +7047,7 @@ dependencies = [ "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] @@ -6885,6 +7070,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/Cargo.toml b/Cargo.toml index 5f2b05fb77..29ae5631fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,8 @@ dashmap = "7.0.0-rc2" async-openai = { version = "0.34.0", default-features = false, features = ["response-types"] } # Using only types, not the API client - reduces dependencies google-cloud-auth = "1.8.0" # Google Cloud authentication with automatic token refresh +jsonrpsee = { version = "0.24", features = ["server", "macros"] } + # Internal crates forge_embed = { path = "crates/forge_embed" } forge_api = { path = "crates/forge_api" } @@ -162,3 +164,4 @@ forge_test_kit = { path = "crates/forge_test_kit" } forge_markdown_stream = { path = "crates/forge_markdown_stream" } forge_config = { path = "crates/forge_config" } +forge_jsonrpc = { path = "crates/forge_jsonrpc" } diff --git a/crates/forge_jsonrpc/Cargo.toml b/crates/forge_jsonrpc/Cargo.toml new file mode 100644 index 0000000000..3756df1aa2 --- /dev/null +++ b/crates/forge_jsonrpc/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "forge_jsonrpc" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +tracing.workspace = true +url.workspace = true +jsonrpsee = { version = "0.24", features = ["server", "macros"] } +schemars = { version = "0.8", features = ["derive"] } +async-trait.workspace = true +dirs.workspace = true +futures.workspace = true + +# Internal crates +forge_api.workspace = true +forge_domain.workspace = true +forge_stream.workspace = true +forge_app.workspace = true + +[dev-dependencies] +forge_config.workspace = true diff --git a/crates/forge_jsonrpc/src/error.rs b/crates/forge_jsonrpc/src/error.rs new file mode 100644 index 0000000000..e515f9b4c5 --- /dev/null +++ b/crates/forge_jsonrpc/src/error.rs @@ -0,0 +1,134 @@ +use jsonrpsee::types::{ErrorObject, ErrorObjectOwned}; +use serde::Serialize; + +/// JSON-RPC error codes +pub struct ErrorCode; + +impl ErrorCode { + /// Parse error (-32700) + pub const PARSE_ERROR: i32 = -32700; + /// Invalid request (-32600) + pub const INVALID_REQUEST: i32 = -32600; + /// Method not found (-32601) + pub const METHOD_NOT_FOUND: i32 = -32601; + /// Invalid params (-32602) + pub const INVALID_PARAMS: i32 = -32602; + /// Internal error (-32603) + pub const INTERNAL_ERROR: i32 = -32603; + /// Server error base (-32000) + pub const SERVER_ERROR: i32 = -32000; + /// Not found (-32001) + pub const NOT_FOUND: i32 = -32001; + /// Unauthorized (-32002) + pub const UNAUTHORIZED: i32 = -32002; + /// Validation failed (-32003) + pub const VALIDATION_FAILED: i32 = -32003; +} + +/// Convert anyhow errors to JSON-RPC errors using proper downcasting +pub fn map_error(err: anyhow::Error) -> ErrorObjectOwned { + // Try to downcast to specific domain error types + if let Some(domain_err) = err.downcast_ref::() { + return map_domain_error(domain_err); + } + + // Try to downcast to app error types + if let Some(app_err) = err.downcast_ref::() { + return map_app_error(app_err); + } + + // Default: return as internal error without string matching + ErrorObject::owned( + ErrorCode::INTERNAL_ERROR, + format!("Internal error: {}", err), + None::<()>, + ) +} + +/// Map domain errors to JSON-RPC errors +fn map_domain_error(err: &forge_domain::Error) -> ErrorObjectOwned { + match err { + forge_domain::Error::ConversationNotFound(_) | + forge_domain::Error::AgentUndefined(_) | + forge_domain::Error::WorkspaceNotFound | + forge_domain::Error::HeadAgentUndefined => { + ErrorObject::owned(ErrorCode::NOT_FOUND, err.to_string(), None::<()>) + } + forge_domain::Error::ProviderNotAvailable { .. } | + forge_domain::Error::EnvironmentVariableNotFound { .. } | + forge_domain::Error::AuthTokenNotFound => { + ErrorObject::owned(ErrorCode::UNAUTHORIZED, err.to_string(), None::<()>) + } + forge_domain::Error::ConversationId(_) | + forge_domain::Error::ToolCallArgument { .. } | + forge_domain::Error::AgentCallArgument { .. } | + forge_domain::Error::ToolCallParse(_) | + forge_domain::Error::ToolCallMissingName | + forge_domain::Error::ToolCallMissingId | + forge_domain::Error::EToolCallArgument(_) | + forge_domain::Error::MissingAgentDescription(_) | + forge_domain::Error::MissingModel(_) | + forge_domain::Error::NoModelDefined(_) | + forge_domain::Error::NoDefaultSession => { + ErrorObject::owned(ErrorCode::VALIDATION_FAILED, err.to_string(), None::<()>) + } + forge_domain::Error::MaxTurnsReached(_, _) | + forge_domain::Error::WorkspaceAlreadyInitialized(_) | + forge_domain::Error::SyncFailed { .. } | + forge_domain::Error::EmptyCompletion | + forge_domain::Error::VertexAiConfiguration { .. } | + forge_domain::Error::Retryable(_) | + forge_domain::Error::UnsupportedRole(_) | + forge_domain::Error::UndefinedVariable(_) => { + ErrorObject::owned(ErrorCode::INTERNAL_ERROR, err.to_string(), None::<()>) + } + } +} + +/// Map app errors to JSON-RPC errors +fn map_app_error(err: &forge_app::Error) -> ErrorObjectOwned { + use forge_app::Error; + match err { + Error::NotFound(_) => ErrorObject::owned(ErrorCode::NOT_FOUND, err.to_string(), None::<()>), + Error::CallArgument(_) | Error::CallTimeout { .. } => { + ErrorObject::owned(ErrorCode::VALIDATION_FAILED, err.to_string(), None::<()>) + } + _ => ErrorObject::owned(ErrorCode::INTERNAL_ERROR, err.to_string(), None::<()>), + } +} + +/// Create a method not found error +pub fn method_not_found(method: &str) -> ErrorObjectOwned { + ErrorObject::owned( + ErrorCode::METHOD_NOT_FOUND, + format!("Method not found: {}", method), + Some(serde_json::json!({ "method": method })), + ) +} + +/// Create an invalid params error +pub fn invalid_params(message: &str, data: T) -> ErrorObjectOwned { + ErrorObject::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid params: {}", message), + Some(data), + ) +} + +/// Create a not found error +pub fn not_found(resource: &str, id: &str) -> ErrorObjectOwned { + ErrorObject::owned( + ErrorCode::NOT_FOUND, + format!("{} not found: {}", resource, id), + Some(serde_json::json!({ "resource": resource, "id": id })), + ) +} + +/// Create an internal error +pub fn internal_error(message: &str) -> ErrorObjectOwned { + ErrorObject::owned( + ErrorCode::INTERNAL_ERROR, + format!("Internal error: {}", message), + None::<()>, + ) +} diff --git a/crates/forge_jsonrpc/src/lib.rs b/crates/forge_jsonrpc/src/lib.rs new file mode 100644 index 0000000000..7220a41112 --- /dev/null +++ b/crates/forge_jsonrpc/src/lib.rs @@ -0,0 +1,10 @@ +pub mod error; +pub mod server; +pub mod transport; +pub mod types; + +pub mod test_utils; + +pub use error::{ErrorCode, map_error}; +pub use server::JsonRpcServer; +pub use transport::stdio::StdioTransport; diff --git a/crates/forge_jsonrpc/src/server.rs b/crates/forge_jsonrpc/src/server.rs new file mode 100644 index 0000000000..1235a22529 --- /dev/null +++ b/crates/forge_jsonrpc/src/server.rs @@ -0,0 +1,2308 @@ +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; + +use forge_api::API; +use forge_domain::{ + AgentId, Conversation, ConversationId, DataGenerationParameters, McpConfig, + ProviderId, Scope, UserPrompt, +}; +use futures::StreamExt; +use jsonrpsee::types::ErrorObjectOwned; +use jsonrpsee::{RpcModule, SubscriptionMessage}; +use serde_json::{Value, json}; +use tracing::debug; + +use crate::error::{ErrorCode, map_error, not_found}; +use crate::transport::stdio::StdioTransport; +use crate::types::*; + +/// Helper to serialize a response value, mapping errors to JSON-RPC error +fn to_json_response(value: T) -> Result { + serde_json::to_value(value).map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INTERNAL_ERROR, + format!("Failed to serialize response: {}", e), + None::<()>, + ) + }) +} + +/// STDIO-based JSON-RPC server wrapping the Forge API +pub struct JsonRpcServer { + api: Arc, + module: RpcModule<()>, +} + +impl JsonRpcServer { + /// Create a new JSON-RPC server with the given API implementation + pub fn new(api: Arc) -> Self { + let mut server = Self { api, module: RpcModule::new(()) }; + server.register_methods(); + server + } + + /// Get a reference to the underlying API + pub fn api(&self) -> &Arc { + &self.api + } + + /// Build the RPC module with all method registrations + fn register_methods(&mut self) { + self.register_discovery_methods(); + self.register_conversation_methods(); + self.register_workspace_methods(); + self.register_config_methods(); + self.register_auth_methods(); + self.register_system_methods(); + } + + /// Register discovery methods (get_models, get_agents, get_tools, discover) + fn register_discovery_methods(&mut self) { + // get_models + let api = self.api.clone(); + self.module + .register_async_method("get_models", move |_, _, _| { + let api = api.clone(); + async move { + let models = api.get_models().await.map_err(map_error)?; + let response: Vec = models + .into_iter() + .map(ModelResponse::from) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_models"); + + // get_agents + let api = self.api.clone(); + self.module + .register_async_method("get_agents", move |_, _, _| { + let api = api.clone(); + async move { + let agents = api.get_agents().await.map_err(map_error)?; + let response: Vec = agents + .into_iter() + .map(AgentResponse::from) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_agents"); + + // get_tools + let api = self.api.clone(); + self.module + .register_async_method("get_tools", move |_, _, _| { + let api = api.clone(); + async move { + let tools = api.get_tools().await.map_err(map_error)?; + let mut all_tools: Vec = Vec::new(); + // System tools - ToolName is a newtype, use .to_string() or .0 + all_tools.extend(tools.system.iter().map(|t| t.name.to_string())); + // Agent tools + all_tools.extend(tools.agents.iter().map(|t| t.name.to_string())); + // MCP tools - iterate through the HashMap + for server_tools in tools.mcp.get_servers().values() { + all_tools.extend(server_tools.iter().map(|t| t.name.to_string())); + } + let response = + ToolsOverviewResponse { enabled: all_tools, disabled: Vec::new() }; + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_tools"); + + // discover + let api = self.api.clone(); + self.module + .register_async_method("discover", move |_, _, _| { + let api = api.clone(); + async move { + let files = api.discover().await.map_err(map_error)?; + let response: Vec = files + .into_iter() + .map(FileResponse::from) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register discover"); + + // get_providers + let api = self.api.clone(); + self.module + .register_async_method("get_providers", move |_, _, _| { + let api = api.clone(); + async move { + let providers = api.get_providers().await.map_err(map_error)?; + let response: Vec = providers + .into_iter() + .map(ProviderResponse::from) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_providers"); + + // get_all_provider_models + let api = self.api.clone(); + self.module + .register_async_method("get_all_provider_models", move |_, _, _| { + let api = api.clone(); + async move { + let provider_models = api.get_all_provider_models().await.map_err(map_error)?; + let response: Vec = provider_models + .into_iter() + .map(ProviderModelsResponse::from) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_all_provider_models"); + + // get_provider - Get a single provider by ID + let api = self.api.clone(); + self.module + .register_async_method("get_provider", move |params, _, _| { + let api = api.clone(); + async move { + let provider_id_str: String = params.parse()?; + let provider_id = ProviderId::from_str(&provider_id_str).map_err(|_| { + ErrorObjectOwned::owned(-32602, "Invalid provider ID", None::<()>) + })?; + + let provider = api.get_provider(&provider_id).await.map_err(map_error)?; + + let response = ProviderResponse { + id: provider.id().to_string(), + name: provider.id().to_string(), + api_key: None, + }; + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_provider"); + + // get_agent_provider - Get provider for a specific agent + let api = self.api.clone(); + self.module + .register_async_method("get_agent_provider", move |params, _, _| { + let api = api.clone(); + async move { + let agent_id_str: String = params.parse()?; + let agent_id = AgentId::new(&agent_id_str); + + let provider = api.get_agent_provider(agent_id).await.map_err(map_error)?; + + let response = ProviderResponse { + id: provider.id.to_string(), + name: provider.id.to_string(), + api_key: None, + }; + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_agent_provider"); + + // get_default_provider - Get the default provider + let api = self.api.clone(); + self.module + .register_async_method("get_default_provider", move |_, _, _| { + let api = api.clone(); + async move { + let provider = api.get_default_provider().await.map_err(map_error)?; + + let response = ProviderResponse { + id: provider.id.to_string(), + name: provider.id.to_string(), + api_key: None, + }; + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_default_provider"); + + // get_schema - returns the API schema for all methods + self.module + .register_method("get_schema", |_, _, _| { + // Return the complete API schema + let schema = get_api_schema(); + Ok::<_, ErrorObjectOwned>(schema) + }) + .expect("Failed to register get_schema"); + + // get_methods - returns a list of all available methods with their signatures + self.module + .register_method("get_methods", |_, _, _| { + let methods = get_all_jsonrpc_methods(); + Ok::<_, ErrorObjectOwned>(json!({ + "version": "1.0.0", + "methods": methods + })) + }) + .expect("Failed to register get_methods"); + + // get_types - returns all type definitions + self.module + .register_method("get_types", |_, _, _| { + let types = get_all_types(); + Ok::<_, ErrorObjectOwned>(json!({ + "version": "1.0.0", + "types": types + })) + }) + .expect("Failed to register get_types"); + + // rpc.discover - OpenRPC standard discovery method + self.module + .register_method("rpc.discover", |_, _, _| { + let schema = get_openrpc_schema(); + Ok::<_, ErrorObjectOwned>(schema) + }) + .expect("Failed to register rpc.discover"); + + // rpc.methods - Standard JSON-RPC method enumeration + self.module + .register_method("rpc.methods", |_, _, _| { + let methods = get_all_jsonrpc_methods(); + Ok::<_, ErrorObjectOwned>(json!({ + "methods": methods.iter().map(|m| m["name"].as_str().unwrap_or("")).collect::>() + })) + }) + .expect("Failed to register rpc.methods"); + + // rpc.describe - Describe a specific method (standard introspection) + self.module + .register_method("rpc.describe", |params, _, _| { + let method_name: String = params.parse()?; + let all_methods = get_all_jsonrpc_methods(); + let method = all_methods + .iter() + .find(|m| m["name"].as_str() == Some(&method_name)); + + match method { + Some(m) => Ok::<_, ErrorObjectOwned>(m.clone()), + None => Err(ErrorObjectOwned::owned( + -32602, + format!("Method '{}' not found", method_name), + None::<()>, + )), + } + }) + .expect("Failed to register rpc.describe"); + } + + /// Register conversation methods + fn register_conversation_methods(&mut self) { + let _api = self.api.clone(); + + // get_conversations + let api = self.api.clone(); + self.module + .register_async_method("get_conversations", move |params, _, _| { + let api = api.clone(); + async move { + let limit: Option = params.parse().ok(); + + let conversations = api.get_conversations(limit).await.map_err(map_error)?; + let response: Vec = conversations + .into_iter() + .map(|c| ConversationResponse { + id: c.id.into_string(), + title: c.title, + created_at: c.metadata.created_at.to_rfc3339(), + updated_at: c.metadata.updated_at.map(|t| t.to_rfc3339()), + message_count: c.context.as_ref().map(|ctx| ctx.messages.len()), + }) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_conversations"); + + // conversation (get single conversation) + let api = self.api.clone(); + self.module + .register_async_method("conversation", move |params, _, _| { + let api = api.clone(); + async move { + let params: ConversationParams = params.parse()?; + let conversation_id = + ConversationId::parse(¶ms.conversation_id).map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid conversation_id: {}", e), + None::<()>, + ) + })?; + + let conversation = api + .conversation(&conversation_id) + .await + .map_err(map_error)?; + + match conversation { + Some(c) => { + let response = ConversationResponse { + id: c.id.into_string(), + title: c.title, + created_at: c.metadata.created_at.to_rfc3339(), + updated_at: c.metadata.updated_at.map(|t| t.to_rfc3339()), + message_count: c.context.as_ref().map(|ctx| ctx.messages.len()), + }; + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + None => Err(not_found("Conversation", ¶ms.conversation_id)), + } + } + }) + .expect("Failed to register conversation"); + + // upsert_conversation + let api = self.api.clone(); + self.module + .register_async_method("upsert_conversation", move |params, _, _| { + let api = api.clone(); + async move { + // Parse the conversation from params before spawning + let conversation_json: Value = params.parse()?; + let conversation: Conversation = serde_json::from_value(conversation_json) + .map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid conversation: {}", e), + None::<()>, + ) + })?; + + api.upsert_conversation(conversation) + .await + .map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register upsert_conversation"); + + // delete_conversation + let api = self.api.clone(); + self.module + .register_async_method("delete_conversation", move |params, _, _| { + let api = api.clone(); + async move { + let params: ConversationParams = params.parse()?; + let conversation_id = + ConversationId::parse(¶ms.conversation_id).map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid conversation_id: {}", e), + None::<()>, + ) + })?; + + api.delete_conversation(&conversation_id) + .await + .map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register delete_conversation"); + + // rename_conversation + let api = self.api.clone(); + self.module + .register_async_method("rename_conversation", move |params, _, _| { + let api = api.clone(); + async move { + let params: RenameConversationParams = params.parse()?; + let conversation_id = + ConversationId::parse(¶ms.conversation_id).map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid conversation_id: {}", e), + None::<()>, + ) + })?; + + api.rename_conversation(&conversation_id, params.title) + .await + .map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register rename_conversation"); + + // last_conversation + let api = self.api.clone(); + self.module + .register_async_method("last_conversation", move |_, _, _| { + let api = api.clone(); + async move { + let conversation = api.last_conversation().await.map_err(map_error)?; + + let response = conversation.map(|c| ConversationResponse { + id: c.id.into_string(), + title: c.title, + created_at: c.metadata.created_at.to_rfc3339(), + updated_at: c.metadata.updated_at.map(|t| t.to_rfc3339()), + message_count: c.context.as_ref().map(|ctx| ctx.messages.len()), + }); + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register last_conversation"); + + // compact_conversation + let api = self.api.clone(); + self.module + .register_async_method("compact_conversation", move |params, _, _| { + let api = api.clone(); + async move { + let params: CompactConversationParams = params.parse()?; + let conversation_id = + ConversationId::parse(¶ms.conversation_id).map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid conversation_id: {}", e), + None::<()>, + ) + })?; + + let result = api + .compact_conversation(&conversation_id) + .await + .map_err(map_error)?; + + let response = CompactionResultResponse { + original_tokens: result.original_tokens, + compacted_tokens: result.compacted_tokens, + original_messages: result.original_messages, + compacted_messages: result.compacted_messages, + }; + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register compact_conversation"); + + // chat.stream - method-based streaming for STDIO + let api = self.api.clone(); + self.module + .register_async_method("chat.stream", move |params, _, _| { + let api = api.clone(); + async move { + use forge_domain::ChatRequest; + + let params: ChatParams = params.parse()?; + let conversation_id = ConversationId::parse(¶ms.conversation_id) + .map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid conversation_id: {}", e), + None::<()>, + ) + })?; + + let event = forge_domain::Event::new(forge_domain::EventValue::text(params.message)); + let chat_req = ChatRequest::new(event, conversation_id); + + let mut stream = api.chat(chat_req).await.map_err(map_error)?; + let mut messages = Vec::new(); + + while let Some(msg) = stream.next().await { + let msg = msg.map_err(map_error)?; + // Convert ChatResponse to a simple JSON representation + let json_msg = match msg { + forge_domain::ChatResponse::TaskMessage { content, .. } => { + json!({ + "type": "message", + "content": content.as_str() + }) + } + forge_domain::ChatResponse::TaskReasoning { content } => { + json!({ + "type": "reasoning", + "content": content + }) + } + forge_domain::ChatResponse::TaskComplete => { + json!({ + "type": "complete" + }) + } + forge_domain::ChatResponse::ToolCallStart { .. } => { + json!({ + "type": "tool_start" + }) + } + forge_domain::ChatResponse::ToolCallEnd(_) => { + json!({ + "type": "tool_end" + }) + } + forge_domain::ChatResponse::RetryAttempt { cause, .. } => { + json!({ + "type": "retry", + "cause": cause.as_str() + }) + } + forge_domain::ChatResponse::Interrupt { reason } => { + json!({ + "type": "interrupt", + "reason": format!("{:?}", reason) + }) + } + }; + messages.push(json!({ + "type": "chunk", + "data": json_msg + })); + } + + // Add a final complete marker + messages.push(json!({ + "type": "complete" + })); + + Ok::<_, ErrorObjectOwned>(json!({ + "stream": messages + })) + } + }) + .expect("Failed to register chat.stream"); + } + + /// Register workspace methods + fn register_workspace_methods(&mut self) { + let _api = self.api.clone(); + + // list_workspaces + let api = self.api.clone(); + self.module + .register_async_method("list_workspaces", move |_, _, _| { + let api = api.clone(); + async move { + let workspaces = api.list_workspaces().await.map_err(map_error)?; + let response: Vec = workspaces + .into_iter() + .map(|w| WorkspaceInfoResponse { + workspace_id: w.workspace_id.to_string(), + working_dir: w.working_dir, + node_count: w.node_count, + relation_count: w.relation_count, + last_updated: w.last_updated.map(|t| t.to_rfc3339()), + created_at: w.created_at.to_rfc3339(), + }) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register list_workspaces"); + + // get_workspace_info + let api = self.api.clone(); + self.module + .register_async_method("get_workspace_info", move |params, _, _| { + let api = api.clone(); + async move { + let params: WorkspacePathParams = params.parse()?; + let path = PathBuf::from(¶ms.path); + + let info = api.get_workspace_info(path).await.map_err(map_error)?; + + let response = info.map(|w| WorkspaceInfoResponse { + workspace_id: w.workspace_id.to_string(), + working_dir: w.working_dir, + node_count: w.node_count, + relation_count: w.relation_count, + last_updated: w.last_updated.map(|t| t.to_rfc3339()), + created_at: w.created_at.to_rfc3339(), + }); + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_workspace_info"); + + // delete_workspaces + let api = self.api.clone(); + self.module + .register_async_method("delete_workspaces", move |params, _, _| { + let api = api.clone(); + async move { + let params: DeleteWorkspacesParams = params.parse()?; + + let ids: Vec = params + .workspace_ids + .into_iter() + .map(|id| { + forge_domain::WorkspaceId::from_string(&id).map_err(|e| { + ErrorObjectOwned::owned( + -32602, + format!("Invalid workspace ID: {}", e), + None::<()>, + ) + }) + }) + .collect::, _>>()?; + + api.delete_workspaces(ids).await.map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register delete_workspaces"); + + // get_workspace_status + let api = self.api.clone(); + self.module + .register_async_method("get_workspace_status", move |params, _, _| { + let api = api.clone(); + async move { + let params: WorkspacePathParams = params.parse()?; + let path = PathBuf::from(¶ms.path); + + let statuses = api.get_workspace_status(path).await.map_err(map_error)?; + let response: Vec = statuses + .into_iter() + .map(|s| FileStatusResponse { + path: s.path, + status: format!("{:?}", s.status), + }) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_workspace_status"); + + // sync_workspace (streaming via subscription) + let api = self.api.clone(); + self.module + .register_subscription( + "sync_workspace.subscribe", + "sync_workspace.notification", + "sync_workspace.unsubscribe", + move |params, pending, _, _| { + let api = api.clone(); + async move { + let params: SyncWorkspaceParams = params.parse()?; + let path = PathBuf::from(¶ms.path); + + let stream = api.sync_workspace(path).await.map_err(map_error)?; + let sink = pending.accept().await?; + + tokio::spawn(async move { + let mut stream = stream; + while let Some(result) = stream.next().await { + let msg = match result { + Ok(progress) => { + let data = match &progress { + forge_domain::SyncProgress::Syncing { + current, + total, + } => { + json!({ + "type": "Syncing", + "current": current, + "total": total, + }) + } + forge_domain::SyncProgress::Completed { + uploaded_files, + total_files, + failed_files, + } => { + json!({ + "type": "Completed", + "uploaded_files": uploaded_files, + "total_files": total_files, + "failed_files": failed_files, + }) + } + forge_domain::SyncProgress::Starting => { + json!({"type": "Starting"}) + } + forge_domain::SyncProgress::WorkspaceCreated { + workspace_id, + } => { + json!({ + "type": "WorkspaceCreated", + "workspace_id": workspace_id.to_string(), + }) + } + forge_domain::SyncProgress::DiscoveringFiles { + workspace_id, + path, + } => { + json!({ + "type": "DiscoveringFiles", + "workspace_id": workspace_id.to_string(), + "path": path.to_string_lossy().to_string(), + }) + } + forge_domain::SyncProgress::FilesDiscovered { + count, + } => { + json!({ + "type": "FilesDiscovered", + "count": count, + }) + } + forge_domain::SyncProgress::ComparingFiles { + remote_files, + local_files, + } => { + json!({ + "type": "ComparingFiles", + "remote_files": remote_files, + "local_files": local_files, + }) + } + forge_domain::SyncProgress::DiffComputed { + added, + deleted, + modified, + } => { + json!({ + "type": "DiffComputed", + "added": added, + "deleted": deleted, + "modified": modified, + }) + } + }; + StreamMessage::Chunk { data } + } + Err(e) => StreamMessage::Error { message: format!("{:#}", e) }, + }; + + let sub_msg = + SubscriptionMessage::from_json(&msg).unwrap_or_else(|_| { + SubscriptionMessage::from_json(&json!({"status": "error"})) + .expect("fallback message should never fail") + }); + if sink.send(sub_msg).await.is_err() { + debug!("Client disconnected from sync_workspace stream"); + break; + } + } + + let complete_msg = + SubscriptionMessage::from_json(&StreamMessage::Complete) + .unwrap_or_else(|_| { + SubscriptionMessage::from_json(&json!({"status": "complete"})) + .unwrap_or_else(|_| SubscriptionMessage::from_json(&json!({"done": true})).expect("fallback message should never fail")) + }); + let _ = sink.send(complete_msg).await; + }); + + Ok(()) + } + }, + ) + .expect("Failed to register sync_workspace subscription"); + + // query_workspace + let api = self.api.clone(); + self.module + .register_async_method("query_workspace", move |params, _, _| { + let api = api.clone(); + async move { + let params: QueryWorkspaceParams = params.parse()?; + let path = PathBuf::from(¶ms.path); + + let search_params = + forge_domain::SearchParams::new(¶ms.query, "semantic search") + .limit(params.limit.unwrap_or(10)); + + let nodes = api + .query_workspace(path, search_params) + .await + .map_err(map_error)?; + + let response: Vec = nodes + .into_iter() + .map(|n| { + let (path, content) = match &n.node { + forge_domain::NodeData::FileChunk(chunk) => { + (Some(chunk.file_path.clone()), Some(chunk.content.clone())) + } + forge_domain::NodeData::File(file) => { + (Some(file.file_path.clone()), Some(file.content.clone())) + } + forge_domain::NodeData::FileRef(file_ref) => { + (Some(file_ref.file_path.clone()), None) + } + forge_domain::NodeData::Note(note) => { + (None, Some(note.content.clone())) + } + forge_domain::NodeData::Task(task) => { + (None, Some(task.task.clone())) + } + }; + NodeResponse { + node_id: n.node_id.to_string(), + path, + content, + relevance: n.relevance, + distance: n.distance, + } + }) + .collect(); + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register query_workspace"); + } + + /// Register configuration methods + fn register_config_methods(&mut self) { + let _api = self.api.clone(); + + // read_mcp_config + let api = self.api.clone(); + self.module + .register_async_method("read_mcp_config", move |params, _, _| { + let api = api.clone(); + async move { + let params: McpConfigParams = params.parse()?; + let scope = params.scope.map(|s| match s.as_str() { + "user" => Scope::User, + "project" | "local" => Scope::Local, + _ => Scope::Local, + }); + + let config = api + .read_mcp_config(scope.as_ref()) + .await + .map_err(map_error)?; + let response = config; + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register read_mcp_config"); + + // write_mcp_config + let api = self.api.clone(); + self.module + .register_async_method("write_mcp_config", move |params, _, _| { + let api = api.clone(); + async move { + let params: WriteMcpConfigParams = params.parse()?; + let scope = match params.scope.as_str() { + "user" => Scope::User, + "project" | "local" => Scope::Local, + _ => Scope::Local, + }; + + let config: McpConfig = serde_json::from_value(params.config).map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid config: {}", e), + None::<()>, + ) + })?; + + api.write_mcp_config(&scope, &config) + .await + .map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register write_mcp_config"); + + // update_config + let api = self.api.clone(); + self.module + .register_async_method("update_config", move |params, _, _| { + let api = api.clone(); + async move { + let params: ConfigParams = params.parse()?; + + // Convert typed DTOs to domain ConfigOperations + let ops: Vec = params + .ops + .into_iter() + .map(|op| { + op.into_domain().map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid config operation: {}", e), + None::<()>, + ) + }) + }) + .collect::, _>>()?; + + api.update_config(ops).await.map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register update_config"); + + // get_commit_config + let api = self.api.clone(); + self.module + .register_async_method("get_commit_config", move |_, _, _| { + let api = api.clone(); + async move { + let config = api.get_commit_config().await.map_err(map_error)?; + let json = serde_json::to_value(&config).unwrap_or(json!(null)); + Ok::<_, ErrorObjectOwned>(json) + } + }) + .expect("Failed to register get_commit_config"); + + // get_suggest_config + let api = self.api.clone(); + self.module + .register_async_method("get_suggest_config", move |_, _, _| { + let api = api.clone(); + async move { + let config = api.get_suggest_config().await.map_err(map_error)?; + let json = serde_json::to_value(&config).unwrap_or(json!(null)); + Ok::<_, ErrorObjectOwned>(json) + } + }) + .expect("Failed to register get_suggest_config"); + + // get_reasoning_effort + let api = self.api.clone(); + self.module + .register_async_method("get_reasoning_effort", move |_, _, _| { + let api = api.clone(); + async move { + let effort = api.get_reasoning_effort().await.map_err(map_error)?; + let json = serde_json::to_value(&effort).unwrap_or(json!(null)); + Ok::<_, ErrorObjectOwned>(json) + } + }) + .expect("Failed to register get_reasoning_effort"); + + // reload_mcp + let api = self.api.clone(); + self.module + .register_async_method("reload_mcp", move |_, _, _| { + let api = api.clone(); + async move { + api.reload_mcp().await.map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register reload_mcp"); + + // get_active_agent + let api = self.api.clone(); + self.module + .register_async_method("get_active_agent", move |_, _, _| { + let api = api.clone(); + async move { + let agent_id = api.get_active_agent().await; + let json = serde_json::to_value(agent_id.map(|id| id.to_string())) + .unwrap_or(json!(null)); + Ok::<_, ErrorObjectOwned>(json) + } + }) + .expect("Failed to register get_active_agent"); + + // set_active_agent + let api = self.api.clone(); + self.module + .register_async_method("set_active_agent", move |params, _, _| { + let api = api.clone(); + async move { + let params: SetActiveAgentParams = params.parse()?; + let agent_id = AgentId::new(¶ms.agent_id); + + api.set_active_agent(agent_id).await.map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register set_active_agent"); + + // get_agent_model + let api = self.api.clone(); + self.module + .register_async_method("get_agent_model", move |params, _, _| { + let api = api.clone(); + async move { + let agent_id_str: String = params.parse()?; + let agent_id = AgentId::new(&agent_id_str); + + let model_id = api.get_agent_model(agent_id).await; + let json = serde_json::to_value(model_id.map(|id| id.to_string())) + .unwrap_or(json!(null)); + Ok::<_, ErrorObjectOwned>(json) + } + }) + .expect("Failed to register get_agent_model"); + + // get_default_model + let api = self.api.clone(); + self.module + .register_async_method("get_default_model", move |_, _, _| { + let api = api.clone(); + async move { + let model_id = api.get_default_model().await; + let json = serde_json::to_value(model_id.map(|id| id.to_string())) + .unwrap_or(json!(null)); + Ok::<_, ErrorObjectOwned>(json) + } + }) + .expect("Failed to register get_default_model"); + } + + /// Register authentication and user methods + fn register_auth_methods(&mut self) { + let _api = self.api.clone(); + + // user_info + let api = self.api.clone(); + self.module + .register_async_method("user_info", move |_, _, _| { + let api = api.clone(); + async move { + let user_info = api.user_info().await.map_err(map_error)?; + + let response = user_info.map(|u| UserInfoResponse { + auth_provider_id: u.auth_provider_id.into_string(), + }); + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register user_info"); + + // user_usage + let api = self.api.clone(); + self.module + .register_async_method("user_usage", move |_, _, _| { + let api = api.clone(); + async move { + let usage = api.user_usage().await.map_err(map_error)?; + + let response = usage.map(|u| UserUsageResponse { + plan_type: u.plan.r#type, + current: u.usage.current, + limit: u.usage.limit, + remaining: u.usage.remaining, + reset_in: u.usage.reset_in, + }); + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register user_usage"); + + // is_authenticated + let api = self.api.clone(); + self.module + .register_async_method("is_authenticated", move |_, _, _| { + let api = api.clone(); + async move { + let authenticated = api.is_authenticated().await.map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!(authenticated)) + } + }) + .expect("Failed to register is_authenticated"); + + // init_provider_auth + let api = self.api.clone(); + self.module + .register_async_method("init_provider_auth", move |params, _, _| { + let api = api.clone(); + async move { + let params: ProviderAuthParams = params.parse()?; + let provider_id = ProviderId::from_str(¶ms.provider_id).map_err(|_| { + ErrorObjectOwned::owned(-32602, "Invalid provider ID", None::<()>,) + })?; + // Parse the auth method - for OAuth variants we need a config which isn't provided here + // So we'll return an error for OAuth or default to ApiKey + let method = match params.method.as_str() { + "api_key" => forge_domain::AuthMethod::ApiKey, + "google_adc" => forge_domain::AuthMethod::GoogleAdc, + _ => return Err(ErrorObjectOwned::owned( + -32602, + format!("Auth method '{}' requires OAuth config which is not supported via JSON-RPC. Use 'api_key' or 'google_adc'.", params.method), + None::<()>, + )), + }; + + let context = api.init_provider_auth(provider_id, method).await.map_err(map_error)?; + + let response = match context { + forge_domain::AuthContextRequest::ApiKey(req) => AuthContextRequestResponse { + url: None, + message: Some(format!("API Key required. Required params: {:?}", req.required_params)), + }, + forge_domain::AuthContextRequest::DeviceCode(req) => AuthContextRequestResponse { + url: Some(req.verification_uri.to_string()), + message: Some(format!("User code: {}. Please visit the URL to authenticate.", req.user_code)), + }, + forge_domain::AuthContextRequest::Code(req) => AuthContextRequestResponse { + url: Some(req.authorization_url.to_string()), + message: Some(format!("Please visit the URL to authenticate. State: {:?}", req.state)), + }, + }; + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register init_provider_auth"); + + // complete_provider_auth - Complete provider authentication and save credentials + let api = self.api.clone(); + self.module + .register_async_method("complete_provider_auth", move |params, _, _| { + let api = api.clone(); + async move { + let params: CompleteProviderAuthParams = params.parse()?; + let provider_id = ProviderId::from_str(¶ms.provider_id).map_err(|_| { + ErrorObjectOwned::owned(-32602, "Invalid provider ID", None::<()>) + })?; + + // Build the appropriate AuthContextResponse based on flow type + let context_response = match params.flow_type.as_str() { + "api_key" => { + let api_key = params.api_key.ok_or_else(|| { + ErrorObjectOwned::owned( + -32602, + "api_key field is required for api_key flow", + None::<()>, + ) + })?; + + // Parse URL params if provided + let url_params = params.url_params.unwrap_or_default(); + let url_params_map: std::collections::HashMap<_, _> = url_params + .into_iter() + .map(|(k, v)| { + (forge_domain::URLParam::from(k), forge_domain::URLParamValue::from(v)) + }) + .collect(); + + // Build the API key request + let api_key_request = forge_domain::ApiKeyRequest { + required_params: vec![], + existing_params: None, + api_key: None, + }; + + forge_domain::AuthContextResponse::api_key( + api_key_request, + api_key, + url_params_map.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(), + ) + } + "device_code" => { + // Device code requires the original request which we don't have + // For JSON-RPC, this is a limitation - the client would need to + // complete device code flow out-of-band + return Err(ErrorObjectOwned::owned( + -32603, + "Device code flow completion requires the original request context which is not available via JSON-RPC. Please use the web UI or CLI for device code authentication.", + None::<()>, + )); + } + "code" => { + let _code = params.code.ok_or_else(|| { + ErrorObjectOwned::owned( + -32602, + "code field is required for authorization_code flow", + None::<()>, + ) + })?; + + // Authorization code flow also requires the original request + return Err(ErrorObjectOwned::owned( + -32603, + "Authorization code flow completion requires the original request context (PKCE verifier, state) which is not available via JSON-RPC. Please use the web UI or CLI for OAuth authentication.", + None::<()>, + )); + } + _ => { + return Err(ErrorObjectOwned::owned( + -32602, + format!("Unknown flow_type: {}. Supported: api_key", params.flow_type), + None::<()>, + )); + } + }; + + let timeout = std::time::Duration::from_secs(params.timeout_seconds.unwrap_or(60)); + + api.complete_provider_auth(provider_id, context_response, timeout) + .await + .map_err(map_error)?; + + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register complete_provider_auth"); + + // remove_provider + let api = self.api.clone(); + self.module + .register_async_method("remove_provider", move |params, _, _| { + let api = api.clone(); + async move { + let provider_id_str: String = params.parse()?; + let provider_id = ProviderId::from_str(&provider_id_str).map_err(|_| { + ErrorObjectOwned::owned(-32602, "Invalid provider ID", None::<()>) + })?; + + api.remove_provider(&provider_id).await.map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register remove_provider"); + + // create_auth_credentials + let api = self.api.clone(); + self.module + .register_async_method("create_auth_credentials", move |_, _, _| { + let api = api.clone(); + async move { + let auth = api.create_auth_credentials().await.map_err(map_error)?; + let response = auth; + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register create_auth_credentials"); + + // migrate_env_credentials + let api = self.api.clone(); + self.module + .register_async_method("migrate_env_credentials", move |_, _, _| { + let api = api.clone(); + async move { + let result = api.migrate_env_credentials().await.map_err(map_error)?; + let json_value = result.map(|r| json!({ + "credentials_path": r.credentials_path.to_string_lossy().to_string(), + "migrated_providers": r.migrated_providers.iter().map(|p| p.to_string()).collect::>(), + })).unwrap_or(json!(null)); + Ok::<_, ErrorObjectOwned>(json_value) + } + }) + .expect("Failed to register migrate_env_credentials"); + + // mcp_auth + let api = self.api.clone(); + self.module + .register_async_method("mcp_auth", move |params, _, _| { + let api = api.clone(); + async move { + let params: McpAuthParams = params.parse()?; + + api.mcp_auth(¶ms.server_url).await.map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register mcp_auth"); + + // mcp_logout + let api = self.api.clone(); + self.module + .register_async_method("mcp_logout", move |params, _, _| { + let api = api.clone(); + async move { + let params: McpLogoutParams = params.parse()?; + + api.mcp_logout(params.server_url.as_deref()) + .await + .map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register mcp_logout"); + + // mcp_auth_status + let api = self.api.clone(); + self.module + .register_async_method("mcp_auth_status", move |params, _, _| { + let api = api.clone(); + async move { + let params: McpAuthStatusParams = params.parse()?; + + let status = api + .mcp_auth_status(¶ms.server_url) + .await + .map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!(status)) + } + }) + .expect("Failed to register mcp_auth_status"); + } + + /// Register system methods (execute_shell_command, get_commands, + /// get_skills, etc.) + fn register_system_methods(&mut self) { + // execute_shell_command + let api = self.api.clone(); + self.module + .register_async_method("execute_shell_command", move |params, _, _| { + let api = api.clone(); + async move { + let params: ShellCommandParams = params.parse()?; + let working_dir = params + .working_dir + .map(PathBuf::from) + .unwrap_or_else(|| api.environment().cwd.clone()); + + let output = api + .execute_shell_command(¶ms.command, working_dir) + .await + .map_err(map_error)?; + + let response = CommandOutputResponse { + stdout: output.stdout, + stderr: output.stderr, + exit_code: output.exit_code, + }; + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register execute_shell_command"); + + // execute_shell_command_raw - executes shell command on present stdio + let api = self.api.clone(); + self.module + .register_async_method("execute_shell_command_raw", move |params, _, _| { + let api = api.clone(); + async move { + let params: ShellCommandParams = params.parse()?; + + let exit_status = api + .execute_shell_command_raw(¶ms.command) + .await + .map_err(map_error)?; + + // Convert ExitStatus to a simple integer response + let exit_code = exit_status.code(); + #[cfg(unix)] + let signal = exit_status.signal(); + #[cfg(not(unix))] + let signal: Option = None; + let response = json!({ + "success": exit_status.success(), + "exit_code": exit_code, + "signal": signal, + }); + + Ok::<_, ErrorObjectOwned>(response) + } + }) + .expect("Failed to register execute_shell_command_raw"); + + // get_commands + let api = self.api.clone(); + self.module + .register_async_method("get_commands", move |_, _, _| { + let api = api.clone(); + async move { + let commands = api.get_commands().await.map_err(map_error)?; + let response: Vec = commands + .into_iter() + .map(|c| CommandResponse { name: c.name, description: c.description }) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_commands"); + + // get_skills + let api = self.api.clone(); + self.module + .register_async_method("get_skills", move |_, _, _| { + let api = api.clone(); + async move { + let skills = api.get_skills().await.map_err(map_error)?; + let response: Vec = skills + .into_iter() + .map(|s| SkillResponse { + name: s.name, + path: s.path.map(|p| p.to_string_lossy().to_string()), + command: s.command, + description: s.description, + resources: Some( + s.resources + .iter() + .map(|r| r.to_string_lossy().to_string()) + .collect(), + ) + .filter(|r: &Vec| !r.is_empty()), + }) + .collect(); + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register get_skills"); + + // generate_command + let api = self.api.clone(); + self.module + .register_async_method("generate_command", move |params, _, _| { + let api = api.clone(); + async move { + let params: GenerateCommandParams = params.parse()?; + let prompt: UserPrompt = params.prompt.into(); + + let command = api.generate_command(prompt).await.map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!(command)) + } + }) + .expect("Failed to register generate_command"); + + // commit + let api = self.api.clone(); + self.module + .register_async_method("commit", move |params, _, _| { + let api = api.clone(); + async move { + let params: CommitParams = params.parse()?; + + let result = api + .commit( + params.preview, + params.max_diff_size, + params.diff, + params.additional_context, + ) + .await + .map_err(map_error)?; + + let response = CommitResultResponse { + message: result.message, + has_staged_files: result.has_staged_files, + }; + + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + } + }) + .expect("Failed to register commit"); + + // init_workspace + let api = self.api.clone(); + self.module + .register_async_method("init_workspace", move |params, _, _| { + let api = api.clone(); + async move { + let params: InitWorkspaceParams = params.parse()?; + let path = PathBuf::from(¶ms.path); + + let workspace_id = api.init_workspace(path).await.map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!(workspace_id.to_string())) + } + }) + .expect("Failed to register init_workspace"); + + // environment + let api = self.api.clone(); + self.module + .register_method("environment", move |_, _, _| { + let env = api.environment(); + let response = env; + Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + }) + .expect("Failed to register environment"); + + // hydrate_channel + let api = self.api.clone(); + self.module + .register_async_method("hydrate_channel", move |_, _, _| { + let api = api.clone(); + async move { + api.hydrate_channel().map_err(map_error)?; + Ok::<_, ErrorObjectOwned>(json!({ "success": true })) + } + }) + .expect("Failed to register hydrate_channel"); + + // generate_data (streaming via subscription) + let api = self.api.clone(); + self.module + .register_subscription( + "generate_data.subscribe", + "generate_data.notification", + "generate_data.unsubscribe", + move |params, pending, _, _| { + let api = api.clone(); + async move { + let params: GenerateDataParams = params.parse()?; + + let data_params = DataGenerationParameters { + input: PathBuf::from(params.input), + schema: PathBuf::from(params.schema), + system_prompt: params.system_prompt.map(PathBuf::from), + user_prompt: params.user_prompt.map(PathBuf::from), + concurrency: params.concurrency.unwrap_or(1), + }; + + let stream = api.generate_data(data_params).await.map_err(map_error)?; + let sink = pending.accept().await?; + + tokio::spawn(async move { + let mut stream = stream; + while let Some(result) = stream.next().await { + let msg = match result { + Ok(data) => json!({"type": "chunk", "data": data}), + Err(e) => { + json!({"type": "error", "message": format!("{:#}", e)}) + } + }; + + let sub_msg = match SubscriptionMessage::from_json(&msg) { + Ok(m) => m, + Err(_) => continue, + }; + + if sink.send(sub_msg).await.is_err() { + debug!("Client disconnected from generate_data stream"); + break; + } + } + + if let Ok(msg) = + SubscriptionMessage::from_json(&json!({"type": "complete"})) + { + let _ = sink.send(msg).await; + } + }); + + Ok(()) + } + }, + ) + .expect("Failed to register generate_data subscription"); + } + + /// Run the JSON-RPC server over STDIO (stdin/stdout) + /// + /// This is the pure STDIO transport with zero TCP overhead. + /// Directly reads JSON-RPC requests from stdin and writes responses to + /// stdout using the RpcModule without any intermediate TCP server. + pub async fn run_stdio(self) -> anyhow::Result<()> { + let transport = StdioTransport::new(self.module); + transport.run().await + } + + #[doc(hidden)] + /// Get the RPC module for testing purposes + pub fn into_module(self) -> RpcModule<()> { + self.module + } +} +/// Helper function to convert nested method categories to a flat array +fn get_all_jsonrpc_methods() -> Vec { + let method_categories = get_method_list(); + let mut methods = Vec::new(); + + if let Some(obj) = method_categories.as_object() { + for (category, category_methods) in obj { + if let Some(cat_obj) = category_methods.as_object() { + for (method_name, method_def) in cat_obj { + let mut method_entry = method_def.clone(); + if let Some(obj) = method_entry.as_object_mut() { + obj.insert("name".to_string(), json!(method_name)); + obj.insert("category".to_string(), json!(category)); + } + methods.push(method_entry); + } + } + } + } + + methods +} + +/// Helper function to convert type definitions to a flat array +fn get_all_types() -> Vec { + let type_defs = get_type_definitions(); + let mut types = Vec::new(); + + if let Some(obj) = type_defs.as_object() { + for (type_name, type_def) in obj { + let mut type_entry = type_def.clone(); + if let Some(obj) = type_entry.as_object_mut() { + obj.insert("name".to_string(), json!(type_name)); + } + types.push(type_entry); + } + } + + types +} + +/// Returns the complete API schema as a JSON value +fn get_api_schema() -> Value { + serde_json::json!({ + "jsonrpc": "2.0", + "info": { + "title": "Forge JSON-RPC API", + "version": "1.0.0", + "description": "JSON-RPC API for Forge - AI-powered coding assistant" + }, + "methods": get_method_list(), + "types": get_type_definitions() + }) +} + +/// Returns a list of all available JSON-RPC methods with their signatures +fn get_method_list() -> Value { + serde_json::json!({ + "discovery": { + "get_models": { + "description": "Get all available models", + "params": null, + "returns": { "type": "array", "items": { "$ref": "ModelResponse" } } + }, + "get_agents": { + "description": "List all agents", + "params": null, + "returns": { "type": "array", "items": { "$ref": "AgentResponse" } } + }, + "get_tools": { + "description": "List system/agent/MCP tools", + "params": null, + "returns": { "$ref": "ToolsOverviewResponse" } + }, + "discover": { + "description": "Discover files in workspace", + "params": null, + "returns": { "type": "array", "items": { "$ref": "FileResponse" } } + }, + "get_providers": { + "description": "List all configured providers", + "params": null, + "returns": { "type": "array", "items": { "$ref": "ProviderResponse" } } + }, + "get_all_provider_models": { + "description": "Get models grouped by provider", + "params": null, + "returns": { "type": "array", "items": { "$ref": "ProviderModelsResponse" } } + }, + "get_provider": { + "description": "Get a single provider by ID", + "params": { "type": "string" }, + "returns": { "$ref": "ProviderResponse" } + }, + "get_agent_provider": { + "description": "Get provider for a specific agent", + "params": { "type": "string" }, + "returns": { "$ref": "ProviderResponse" } + }, + "get_default_provider": { + "description": "Get the default provider", + "params": null, + "returns": { "$ref": "ProviderResponse" } + }, + "get_schema": { + "description": "Get the complete API schema", + "params": null, + "returns": { "type": "object" } + }, + "get_methods": { + "description": "Get list of all available methods", + "params": null, + "returns": { "type": "object" } + }, + "get_types": { + "description": "Get all type definitions", + "params": null, + "returns": { "type": "object" } + } + }, + "chat": { + "chat.stream": { + "description": "Stream chat messages for a conversation (method-based for STDIO)", + "params": { "$ref": "ChatParams" }, + "returns": { "type": "object", "properties": { "stream": { "type": "array" } } } + } + }, + "conversations": { + "get_conversations": { + "description": "List all conversations", + "params": null, + "returns": { "type": "array", "items": { "$ref": "ConversationResponse" } } + }, + "conversation": { + "description": "Get a specific conversation", + "params": { "type": "object", "properties": { "conversation_id": { "type": "string" } } }, + "returns": { "$ref": "ConversationResponse" } + }, + "upsert_conversation": { + "description": "Create or update a conversation", + "params": { "type": "object" }, + "returns": { "$ref": "ConversationResponse" } + }, + "delete_conversation": { + "description": "Delete a conversation", + "params": { "type": "object", "properties": { "conversation_id": { "type": "string" } } }, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "rename_conversation": { + "description": "Rename a conversation", + "params": { "$ref": "RenameConversationParams" }, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "last_conversation": { + "description": "Get the most recent conversation", + "params": null, + "returns": { "$ref": "ConversationResponse" } + }, + "compact_conversation": { + "description": "Compact a conversation", + "params": { "type": "object", "properties": { "conversation_id": { "type": "string" } } }, + "returns": { "$ref": "CompactionResultResponse" } + } + }, + "workspace": { + "list_workspaces": { + "description": "List all workspaces", + "params": null, + "returns": { "type": "array", "items": { "$ref": "WorkspaceInfoResponse" } } + }, + "get_workspace_info": { + "description": "Get workspace info by path", + "params": { "type": "object", "properties": { "path": { "type": "string" } } }, + "returns": { "$ref": "WorkspaceInfoResponse" } + }, + "delete_workspaces": { + "description": "Delete workspaces", + "params": { "type": "object", "properties": { "workspace_ids": { "type": "array", "items": { "type": "string" } } } }, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "get_workspace_status": { + "description": "Get workspace status", + "params": { "type": "object", "properties": { "path": { "type": "string" } } }, + "returns": { "type": "array", "items": { "$ref": "FileStatusResponse" } } + }, + "sync_workspace.subscribe": { + "description": "Subscribe to workspace sync progress", + "params": { "$ref": "SyncWorkspaceParams" }, + "returns": { "type": "subscription" }, + "notifications": ["sync_workspace.notification"] + }, + "query_workspace": { + "description": "Query workspace files", + "params": { "$ref": "QueryWorkspaceParams" }, + "returns": { "type": "array", "items": { "$ref": "NodeResponse" } } + } + }, + "config": { + "read_mcp_config": { + "description": "Read MCP configuration", + "params": { "type": "object", "properties": { "scope": { "type": "string" } } }, + "returns": { "type": "object" } + }, + "write_mcp_config": { + "description": "Write MCP configuration", + "params": { "type": "object", "properties": { "scope": { "type": "string" }, "config": { "type": "object" } } }, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "update_config": { + "description": "Update configuration", + "params": { "type": "object", "properties": { "ops": { "type": "array" } } }, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "get_commit_config": { + "description": "Get commit configuration", + "params": null, + "returns": { "type": "object" } + }, + "get_suggest_config": { + "description": "Get suggest configuration", + "params": null, + "returns": { "type": "object" } + }, + "get_reasoning_effort": { + "description": "Get reasoning effort level", + "params": null, + "returns": { "type": "string" } + }, + "reload_mcp": { + "description": "Reload MCP configuration", + "params": null, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "get_active_agent": { + "description": "Get the currently active agent", + "params": null, + "returns": { "type": "string" } + }, + "set_active_agent": { + "description": "Set the active agent", + "params": { "type": "object", "properties": { "agent_id": { "type": "string" } } }, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "get_agent_model": { + "description": "Get the model for an agent", + "params": { "type": "string" }, + "returns": { "type": "string" } + }, + "get_default_model": { + "description": "Get the default model", + "params": null, + "returns": { "type": "string" } + } + }, + "auth": { + "user_info": { + "description": "Get authenticated user info", + "params": null, + "returns": { "$ref": "UserInfoResponse" } + }, + "user_usage": { + "description": "Get user usage information", + "params": null, + "returns": { "$ref": "UserUsageResponse" } + }, + "is_authenticated": { + "description": "Check if user is authenticated", + "params": null, + "returns": { "type": "boolean" } + }, + "init_provider_auth": { + "description": "Initialize provider authentication", + "params": { "type": "object", "properties": { "provider_id": { "type": "string" }, "method": { "type": "string" } } }, + "returns": { "$ref": "AuthContextRequestResponse" } + }, + "remove_provider": { + "description": "Remove a provider", + "params": { "type": "string" }, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "create_auth_credentials": { + "description": "Create authentication credentials", + "params": null, + "returns": { "type": "object" } + }, + "migrate_env_credentials": { + "description": "Migrate environment credentials", + "params": null, + "returns": { "type": "object" } + }, + "mcp_auth": { + "description": "Authenticate with MCP server", + "params": { "type": "object", "properties": { "server_url": { "type": "string" } } }, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "mcp_logout": { + "description": "Logout from MCP server", + "params": { "type": "object", "properties": { "server_url": { "type": "string" } } }, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "mcp_auth_status": { + "description": "Get MCP authentication status", + "params": { "type": "object", "properties": { "server_url": { "type": "string" } } }, + "returns": { "type": "boolean" } + } + }, + "system": { + "execute_shell_command": { + "description": "Execute a shell command", + "params": { "$ref": "ShellCommandParams" }, + "returns": { "$ref": "CommandOutputResponse" } + }, + "get_commands": { + "description": "List available commands", + "params": null, + "returns": { "type": "array", "items": { "$ref": "CommandResponse" } } + }, + "get_skills": { + "description": "List available skills", + "params": null, + "returns": { "type": "array", "items": { "$ref": "SkillResponse" } } + }, + "generate_command": { + "description": "Generate a command from a prompt", + "params": { "type": "object", "properties": { "prompt": { "type": "string" } } }, + "returns": { "type": "string" } + }, + "commit": { + "description": "Generate a commit message", + "params": { "$ref": "CommitParams" }, + "returns": { "$ref": "CommitResultResponse" } + }, + "init_workspace": { + "description": "Initialize a workspace", + "params": { "type": "object", "properties": { "path": { "type": "string" } } }, + "returns": { "type": "string" } + }, + "environment": { + "description": "Get environment information", + "params": null, + "returns": { "type": "object" } + }, + "hydrate_channel": { + "description": "Hydrate the command channel", + "params": null, + "returns": { "type": "object", "properties": { "success": { "type": "boolean" } } } + }, + "generate_data.subscribe": { + "description": "Subscribe to data generation stream", + "params": { "$ref": "GenerateDataParams" }, + "returns": { "type": "subscription" }, + "notifications": ["generate_data.notification"] + } + } + }) +} + +/// Returns all type definitions for the API +fn get_type_definitions() -> Value { + serde_json::json!({ + "ModelResponse": { + "description": "Model information", + "type": "object", + "properties": { + "id": { "type": "string", "description": "Model ID" }, + "name": { "type": "string", "description": "Model name" }, + "provider": { "type": "string", "description": "Provider ID" } + } + }, + "AgentResponse": { + "description": "Agent information", + "type": "object", + "properties": { + "id": { "type": "string", "description": "Agent ID" }, + "name": { "type": "string", "description": "Agent name" }, + "description": { "type": "string", "nullable": true, "description": "Agent description" } + } + }, + "ToolsOverviewResponse": { + "description": "Tools overview", + "type": "object", + "properties": { + "enabled": { "type": "array", "items": { "type": "string" }, "description": "Enabled tool names" }, + "disabled": { "type": "array", "items": { "type": "string" }, "description": "Disabled tool names" } + } + }, + "FileResponse": { + "description": "File information", + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path" }, + "is_dir": { "type": "boolean", "description": "Whether this is a directory" } + } + }, + "ProviderResponse": { + "description": "Provider information", + "type": "object", + "properties": { + "id": { "type": "string", "description": "Provider ID" }, + "name": { "type": "string", "description": "Provider name" }, + "api_key": { "type": "string", "nullable": true, "description": "API key (if available)" } + } + }, + "ProviderModelsResponse": { + "description": "Provider with its models", + "type": "object", + "properties": { + "provider_id": { "type": "string", "description": "Provider ID" }, + "provider_name": { "type": "string", "description": "Provider name" }, + "models": { "type": "array", "items": { "$ref": "ModelResponse" }, "description": "Available models" }, + "error": { "type": "string", "nullable": true, "description": "Error message if failed to fetch models" } + } + }, + "ConversationResponse": { + "description": "Conversation information", + "type": "object", + "properties": { + "id": { "type": "string", "description": "Conversation ID" }, + "title": { "type": "string", "nullable": true, "description": "Conversation title" }, + "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" }, + "updated_at": { "type": "string", "format": "date-time", "nullable": true, "description": "Last update timestamp" }, + "message_count": { "type": "integer", "nullable": true, "description": "Number of messages" } + } + }, + "CompactionResultResponse": { + "description": "Conversation compaction result", + "type": "object", + "properties": { + "original_tokens": { "type": "integer", "description": "Original token count" }, + "compacted_tokens": { "type": "integer", "description": "Compacted token count" }, + "original_messages": { "type": "integer", "description": "Original message count" }, + "compacted_messages": { "type": "integer", "description": "Compacted message count" } + } + }, + "WorkspaceInfoResponse": { + "description": "Workspace information", + "type": "object", + "properties": { + "workspace_id": { "type": "string", "description": "Workspace ID" }, + "working_dir": { "type": "string", "description": "Working directory" }, + "node_count": { "type": "integer", "nullable": true, "description": "Number of nodes" }, + "relation_count": { "type": "integer", "nullable": true, "description": "Number of relations" }, + "last_updated": { "type": "string", "format": "date-time", "nullable": true, "description": "Last update timestamp" }, + "created_at": { "type": "string", "format": "date-time", "description": "Creation timestamp" } + } + }, + "FileStatusResponse": { + "description": "File status in workspace", + "type": "object", + "properties": { + "path": { "type": "string", "description": "File path" }, + "status": { "type": "string", "description": "File status" } + } + }, + "NodeResponse": { + "description": "Search result node", + "type": "object", + "properties": { + "node_id": { "type": "string", "description": "Node ID" }, + "path": { "type": "string", "nullable": true, "description": "File path" }, + "content": { "type": "string", "nullable": true, "description": "Node content" }, + "relevance": { "type": "number", "nullable": true, "description": "Relevance score" }, + "distance": { "type": "number", "nullable": true, "description": "Distance score" } + } + }, + "UserInfoResponse": { + "description": "User information", + "type": "object", + "properties": { + "auth_provider_id": { "type": "string", "description": "Authentication provider ID" } + } + }, + "UserUsageResponse": { + "description": "User usage information", + "type": "object", + "properties": { + "plan_type": { "type": "string", "description": "Plan type" }, + "current": { "type": "integer", "description": "Current usage" }, + "limit": { "type": "integer", "description": "Usage limit" }, + "remaining": { "type": "integer", "description": "Remaining quota" }, + "reset_in": { "type": "integer", "nullable": true, "description": "Seconds until reset" } + } + }, + "CommandResponse": { + "description": "Available command", + "type": "object", + "properties": { + "name": { "type": "string", "description": "Command name" }, + "description": { "type": "string", "description": "Command description" } + } + }, + "SkillResponse": { + "description": "Available skill", + "type": "object", + "properties": { + "name": { "type": "string", "description": "Skill name" }, + "path": { "type": "string", "description": "Skill path" }, + "command": { "type": "string", "description": "Command to invoke skill" }, + "description": { "type": "string", "description": "Skill description" }, + "resources": { "type": "array", "items": { "type": "string" }, "nullable": true, "description": "Skill resources" } + } + }, + "CommandOutputResponse": { + "description": "Shell command output", + "type": "object", + "properties": { + "stdout": { "type": "string", "description": "Standard output" }, + "stderr": { "type": "string", "description": "Standard error" }, + "exit_code": { "type": "integer", "description": "Exit code" } + } + }, + "CommitResultResponse": { + "description": "Commit generation result", + "type": "object", + "properties": { + "message": { "type": "string", "description": "Generated commit message" }, + "has_staged_files": { "type": "boolean", "description": "Whether there are staged files" } + } + }, + "AuthContextRequestResponse": { + "description": "Authentication context request", + "type": "object", + "properties": { + "url": { "type": "string", "nullable": true, "description": "Authentication URL" }, + "message": { "type": "string", "nullable": true, "description": "Authentication message" } + } + }, + "ChatParams": { + "description": "Chat subscription parameters", + "type": "object", + "properties": { + "event": { "type": "object", "description": "Chat event" }, + "conversation_id": { "type": "string", "description": "Conversation ID" } + }, + "required": ["event", "conversation_id"] + }, + "RenameConversationParams": { + "description": "Rename conversation parameters", + "type": "object", + "properties": { + "conversation_id": { "type": "string", "description": "Conversation ID" }, + "title": { "type": "string", "description": "New title" } + }, + "required": ["conversation_id", "title"] + }, + "SyncWorkspaceParams": { + "description": "Sync workspace parameters", + "type": "object", + "properties": { + "path": { "type": "string", "description": "Workspace path" } + } + }, + "QueryWorkspaceParams": { + "description": "Query workspace parameters", + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search query" }, + "path": { "type": "string", "description": "Workspace path" }, + "limit": { "type": "integer", "nullable": true, "description": "Result limit" } + }, + "required": ["query"] + }, + "ShellCommandParams": { + "description": "Shell command parameters", + "type": "object", + "properties": { + "command": { "type": "string", "description": "Command to execute" }, + "working_dir": { "type": "string", "nullable": true, "description": "Working directory" } + }, + "required": ["command"] + }, + "CommitParams": { + "description": "Commit generation parameters", + "type": "object", + "properties": { + "preview": { "type": "boolean", "description": "Preview mode" }, + "max_diff_size": { "type": "integer", "nullable": true, "description": "Maximum diff size" }, + "diff": { "type": "string", "nullable": true, "description": "Diff content" }, + "additional_context": { "type": "string", "nullable": true, "description": "Additional context" } + } + }, + "GenerateDataParams": { + "description": "Data generation parameters", + "type": "object", + "properties": { + "input": { "type": "string", "description": "Path to input JSONL file" }, + "schema": { "type": "string", "description": "Path to JSON schema file" }, + "system_prompt": { "type": "string", "nullable": true, "description": "Path to system prompt template" }, + "user_prompt": { "type": "string", "nullable": true, "description": "Path to user prompt template" }, + "concurrency": { "type": "integer", "nullable": true, "description": "Max concurrent requests" } + }, + "required": ["input", "schema"] + }, + "StreamMessage": { + "description": "Streaming message", + "type": "object", + "properties": { + "type": { "type": "string", "enum": ["chunk", "error", "complete"], "description": "Message type" }, + "data": { "type": "object", "nullable": true, "description": "Message data" }, + "message": { "type": "string", "nullable": true, "description": "Error message" } + } + } + }) +} + +/// Returns the OpenRPC schema following the OpenRPC specification +/// https://spec.open-rpc.org/ +/// Uses schemars to generate schemas from Rust types +fn get_openrpc_schema() -> Value { + use schemars::schema_for; + + // Generate schemas from Rust types using schemars + let schemas = json!({ + "ChatParams": schema_for!(crate::types::ChatParams), + "ConversationParams": schema_for!(crate::types::ConversationParams), + "RenameConversationParams": schema_for!(crate::types::RenameConversationParams), + "ShellCommandParams": schema_for!(crate::types::ShellCommandParams), + "WorkspacePathParams": schema_for!(crate::types::WorkspacePathParams), + "SyncWorkspaceParams": schema_for!(crate::types::SyncWorkspaceParams), + "QueryWorkspaceParams": schema_for!(crate::types::QueryWorkspaceParams), + "ConfigParams": schema_for!(crate::types::ConfigParams), + "McpConfigParams": schema_for!(crate::types::McpConfigParams), + "ProviderAuthParams": schema_for!(crate::types::ProviderAuthParams), + "SetActiveAgentParams": schema_for!(crate::types::SetActiveAgentParams), + "CommitParams": schema_for!(crate::types::CommitParams), + "CompactConversationParams": schema_for!(crate::types::CompactConversationParams), + "GenerateCommandParams": schema_for!(crate::types::GenerateCommandParams), + "GenerateDataParams": schema_for!(crate::types::GenerateDataParams), + "DeleteWorkspacesParams": schema_for!(crate::types::DeleteWorkspacesParams), + "McpAuthParams": schema_for!(crate::types::McpAuthParams), + "McpLogoutParams": schema_for!(crate::types::McpLogoutParams), + "McpAuthStatusParams": schema_for!(crate::types::McpAuthStatusParams), + "WriteMcpConfigParams": schema_for!(crate::types::WriteMcpConfigParams), + "InitWorkspaceParams": schema_for!(crate::types::InitWorkspaceParams), + "ModelResponse": schema_for!(crate::types::ModelResponse), + "AgentResponse": schema_for!(crate::types::AgentResponse), + "FileResponse": schema_for!(crate::types::FileResponse), + "ConversationResponse": schema_for!(crate::types::ConversationResponse), + "CommandOutputResponse": schema_for!(crate::types::CommandOutputResponse), + "WorkspaceInfoResponse": schema_for!(crate::types::WorkspaceInfoResponse), + "FileStatusResponse": schema_for!(crate::types::FileStatusResponse), + "CompactionResultResponse": schema_for!(crate::types::CompactionResultResponse), + "ProviderResponse": schema_for!(crate::types::ProviderResponse), + "UserInfoResponse": schema_for!(crate::types::UserInfoResponse), + "UserUsageResponse": schema_for!(crate::types::UserUsageResponse), + "CommandResponse": schema_for!(crate::types::CommandResponse), + "SkillResponse": schema_for!(crate::types::SkillResponse), + "CommitResultResponse": schema_for!(crate::types::CommitResultResponse), + "AuthContextRequestResponse": schema_for!(crate::types::AuthContextRequestResponse), + "NodeResponse": schema_for!(crate::types::NodeResponse), + "SyncProgressResponse": schema_for!(crate::types::SyncProgressResponse), + "ToolsOverviewResponse": schema_for!(crate::types::ToolsOverviewResponse), + "ProviderModelsResponse": schema_for!(crate::types::ProviderModelsResponse), + "StreamMessage": schema_for!(crate::types::StreamMessage), + }); + + // Build methods using the manually defined list but reference the derived + // schemas + let methods = get_all_jsonrpc_methods(); + let openrpc_methods: Vec = methods + .iter() + .map(|m| { + let name = m.get("name").and_then(|n| n.as_str()).unwrap_or(""); + let desc = m.get("description").and_then(|d| d.as_str()).unwrap_or(""); + let returns = m + .get("returns") + .cloned() + .unwrap_or(json!({"type": "object"})); + + // Use derived schema for params if available + let params = m.get("params").cloned().unwrap_or(json!(null)); + let params_schema = if params.is_null() { + json!([]) + } else { + json!([{ + "name": "params", + "schema": params, + "required": true + }]) + }; + + json!({ + "name": name, + "description": desc, + "params": params_schema, + "result": { + "name": "result", + "schema": returns + } + }) + }) + .collect(); + + serde_json::json!({ + "openrpc": "1.0.0", + "info": { + "title": "Forge JSON-RPC API", + "version": "1.0.0", + "description": "JSON-RPC API for Forge - AI-powered coding assistant" + }, + "methods": openrpc_methods, + "components": { + "schemas": schemas + } + }) +} diff --git a/crates/forge_jsonrpc/src/test_utils.rs b/crates/forge_jsonrpc/src/test_utils.rs new file mode 100644 index 0000000000..6887353c89 --- /dev/null +++ b/crates/forge_jsonrpc/src/test_utils.rs @@ -0,0 +1,375 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use forge_api::UserUsage; +use forge_app::User; +use forge_domain::{ + AgentId, ChatRequest, CommandOutput, Conversation, ConversationId, McpConfig, ProviderId, + Scope, WorkspaceId, WorkspaceInfo, +}; +use futures::stream::BoxStream; +use serde_json::{Value, json}; + +/// Mock API implementation for testing +#[derive(Default)] +pub struct MockAPI { + pub models: Vec, + pub agents: Vec, + pub conversations: Vec, + pub workspaces: Vec, + pub authenticated: bool, +} + + +#[async_trait::async_trait] +impl forge_api::API for MockAPI { + async fn discover(&self) -> anyhow::Result> { + Ok(vec![ + forge_domain::File { path: "test.txt".to_string(), is_dir: false }, + forge_domain::File { path: "src/main.rs".to_string(), is_dir: false }, + ]) + } + + async fn get_tools(&self) -> anyhow::Result { + Ok(forge_api::ToolsOverview { + system: vec![], + agents: vec![], + mcp: forge_domain::McpServers::default(), + }) + } + + async fn get_models(&self) -> anyhow::Result> { + Ok(self.models.clone()) + } + + async fn get_all_provider_models(&self) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_agents(&self) -> anyhow::Result> { + Ok(self.agents.clone()) + } + + async fn get_providers(&self) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_provider(&self, _id: &ProviderId) -> anyhow::Result { + anyhow::bail!("Provider not found") + } + + async fn chat( + &self, + _chat: ChatRequest, + ) -> anyhow::Result>> { + Ok(forge_stream::MpscStream::spawn(|sender| async move { + let _ = sender + .send(Ok(forge_domain::ChatResponse::TaskComplete)) + .await; + })) + } + + async fn commit( + &self, + _preview: bool, + _max_diff_size: Option, + _diff: Option, + _additional_context: Option, + ) -> anyhow::Result { + Ok(forge_app::CommitResult { + message: "test commit".to_string(), + committed: false, + has_staged_files: true, + git_output: String::new(), + }) + } + + fn environment(&self) -> forge_domain::Environment { + forge_domain::Environment { + os: std::env::consts::OS.to_string(), + cwd: std::path::PathBuf::from("."), + home: dirs::home_dir(), + shell: if cfg!(target_os = "windows") { + std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()) + } else { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) + }, + base_path: dirs::home_dir() + .map(|h| h.join(".forge")) + .unwrap_or_else(|| std::path::PathBuf::from(".forge")), + } + } + + async fn upsert_conversation(&self, _conversation: Conversation) -> anyhow::Result<()> { + Ok(()) + } + + async fn conversation( + &self, + conversation_id: &ConversationId, + ) -> anyhow::Result> { + Ok(self + .conversations + .iter() + .find(|c| c.id == *conversation_id) + .cloned()) + } + + async fn get_conversations(&self, _limit: Option) -> anyhow::Result> { + Ok(self.conversations.clone()) + } + + async fn last_conversation(&self) -> anyhow::Result> { + Ok(self.conversations.last().cloned()) + } + + async fn delete_conversation(&self, _conversation_id: &ConversationId) -> anyhow::Result<()> { + Ok(()) + } + + async fn rename_conversation( + &self, + _conversation_id: &ConversationId, + _title: String, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn compact_conversation( + &self, + _conversation_id: &ConversationId, + ) -> anyhow::Result { + Ok(forge_domain::CompactionResult { + original_tokens: 1000, + compacted_tokens: 500, + original_messages: 20, + compacted_messages: 10, + }) + } + + async fn execute_shell_command( + &self, + command: &str, + _working_dir: PathBuf, + ) -> anyhow::Result { + Ok(CommandOutput { + command: command.to_string(), + stdout: format!("Executed: {}", command), + stderr: String::new(), + exit_code: Some(0), + }) + } + + async fn execute_shell_command_raw( + &self, + _command: &str, + ) -> anyhow::Result { + Ok(std::process::ExitStatus::default()) + } + + async fn read_mcp_config(&self, _scope: Option<&Scope>) -> anyhow::Result { + Ok(McpConfig::default()) + } + + async fn write_mcp_config(&self, _scope: &Scope, _config: &McpConfig) -> anyhow::Result<()> { + Ok(()) + } + + async fn get_agent_provider( + &self, + _agent_id: AgentId, + ) -> anyhow::Result> { + anyhow::bail!("Not implemented") + } + + async fn get_default_provider(&self) -> anyhow::Result> { + anyhow::bail!("Not implemented") + } + + async fn update_config(&self, _ops: Vec) -> anyhow::Result<()> { + Ok(()) + } + + async fn user_info(&self) -> anyhow::Result> { + if self.authenticated { + Ok(Some(User { + auth_provider_id: forge_app::AuthProviderId::new("test"), + })) + } else { + Ok(None) + } + } + + async fn user_usage(&self) -> anyhow::Result> { + Ok(None) + } + + async fn get_active_agent(&self) -> Option { + None + } + + async fn set_active_agent(&self, _agent_id: AgentId) -> anyhow::Result<()> { + Ok(()) + } + + async fn get_agent_model(&self, _agent_id: AgentId) -> Option { + None + } + + async fn get_default_model(&self) -> Option { + None + } + + async fn get_commit_config(&self) -> anyhow::Result> { + Ok(None) + } + + async fn get_suggest_config(&self) -> anyhow::Result> { + Ok(None) + } + + async fn get_reasoning_effort(&self) -> anyhow::Result> { + Ok(None) + } + + async fn reload_mcp(&self) -> anyhow::Result<()> { + Ok(()) + } + + async fn get_commands(&self) -> anyhow::Result> { + Ok(vec![]) + } + + async fn get_skills(&self) -> anyhow::Result> { + Ok(vec![]) + } + + async fn generate_command(&self, _prompt: forge_domain::UserPrompt) -> anyhow::Result { + Ok("echo hello".to_string()) + } + + async fn init_provider_auth( + &self, + _provider_id: ProviderId, + _method: forge_domain::AuthMethod, + ) -> anyhow::Result { + Ok(forge_domain::AuthContextRequest::ApiKey( + forge_domain::ApiKeyRequest { + required_params: vec![], + existing_params: None, + api_key: None, + }, + )) + } + + async fn complete_provider_auth( + &self, + _provider_id: ProviderId, + _context: forge_domain::AuthContextResponse, + _timeout: std::time::Duration, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn remove_provider(&self, _provider_id: &ProviderId) -> anyhow::Result<()> { + Ok(()) + } + + async fn sync_workspace( + &self, + _path: PathBuf, + ) -> anyhow::Result>> { + Ok(forge_stream::MpscStream::spawn(|sender| async move { + let _ = sender + .send(Ok(forge_domain::SyncProgress::Syncing { + current: 1, + total: 10, + })) + .await; + })) + } + + async fn query_workspace( + &self, + _path: PathBuf, + _params: forge_domain::SearchParams<'_>, + ) -> anyhow::Result> { + Ok(vec![]) + } + + async fn list_workspaces(&self) -> anyhow::Result> { + Ok(self.workspaces.clone()) + } + + async fn get_workspace_info(&self, _path: PathBuf) -> anyhow::Result> { + Ok(self.workspaces.first().cloned()) + } + + async fn delete_workspaces(&self, _workspace_ids: Vec) -> anyhow::Result<()> { + Ok(()) + } + + async fn get_workspace_status( + &self, + _path: PathBuf, + ) -> anyhow::Result> { + Ok(vec![]) + } + + fn hydrate_channel(&self) -> anyhow::Result<()> { + Ok(()) + } + + async fn is_authenticated(&self) -> anyhow::Result { + Ok(self.authenticated) + } + + async fn create_auth_credentials(&self) -> anyhow::Result { + Ok(forge_domain::WorkspaceAuth::new( + forge_domain::UserId::generate(), + "test".to_string().into(), + )) + } + + async fn init_workspace(&self, _path: PathBuf) -> anyhow::Result { + Ok(WorkspaceId::from_string("test-workspace").unwrap()) + } + + async fn migrate_env_credentials( + &self, + ) -> anyhow::Result> { + Ok(None) + } + + async fn generate_data( + &self, + _data_parameters: forge_domain::DataGenerationParameters, + ) -> anyhow::Result>> { + use futures::stream; + Ok(Box::pin(stream::iter(vec![Ok(json!({"data": "test"}))]))) + } + + async fn mcp_auth(&self, _server_url: &str) -> anyhow::Result<()> { + Ok(()) + } + + async fn mcp_logout(&self, _server_url: Option<&str>) -> anyhow::Result<()> { + Ok(()) + } + + async fn mcp_auth_status(&self, _server_url: &str) -> anyhow::Result { + Ok("authenticated".to_string()) + } +} + +/// Create a test server with mock API +pub fn create_test_server() -> crate::JsonRpcServer { + let api = Arc::new(MockAPI::default()); + crate::JsonRpcServer::new(api) +} + +/// Create a test server with custom mock API +pub fn create_test_server_with_mock(mock: MockAPI) -> crate::JsonRpcServer { + let api = Arc::new(mock); + crate::JsonRpcServer::new(api) +} diff --git a/crates/forge_jsonrpc/src/transport/mod.rs b/crates/forge_jsonrpc/src/transport/mod.rs new file mode 100644 index 0000000000..88439cab12 --- /dev/null +++ b/crates/forge_jsonrpc/src/transport/mod.rs @@ -0,0 +1,11 @@ +pub mod stdio; + +use async_trait::async_trait; +use jsonrpsee::server::Server; + +/// Transport trait for JSON-RPC server +#[async_trait] +pub trait Transport: Send + Sync { + /// Run the transport with the given server + async fn run(&self, server: Server) -> anyhow::Result<()>; +} diff --git a/crates/forge_jsonrpc/src/transport/stdio.rs b/crates/forge_jsonrpc/src/transport/stdio.rs new file mode 100644 index 0000000000..375b4c1314 --- /dev/null +++ b/crates/forge_jsonrpc/src/transport/stdio.rs @@ -0,0 +1,129 @@ +use std::sync::Arc; + +use jsonrpsee::server::RpcModule; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::Mutex; +use tracing::{debug, error, trace}; + +/// STDIO transport for JSON-RPC +/// Reads JSON-RPC requests from stdin and writes responses to stdout +/// Directly executes methods using the RpcModule without any TCP server +pub struct StdioTransport { + module: RpcModule<()>, +} + +impl StdioTransport { + pub fn new(module: RpcModule<()>) -> Self { + Self { module } + } + + /// Run the STDIO transport loop + pub async fn run(self) -> anyhow::Result<()> { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + let reader = BufReader::new(stdin); + let lines = reader.lines(); + let stdout = Arc::new(Mutex::new(stdout)); + + debug!("STDIO transport started (direct mode, no TCP)"); + + let mut lines = lines; + + while let Ok(Some(line)) = lines.next_line().await { + trace!("Received line: {}", line); + + // Parse the JSON-RPC request + let request: Value = match serde_json::from_str(&line) { + Ok(req) => req, + Err(e) => { + error!("Failed to parse JSON-RPC request: {}", e); + let error_response = serde_json::json!({ + "jsonrpc": "2.0", + "id": null, + "error": { + "code": -32700, + "message": format!("Parse error: {}", e) + } + }); + Self::write_response(&stdout, &error_response).await?; + continue; + } + }; + + // Execute the request + let request_str = serde_json::to_string(&request)?; + + match self + .module + .raw_json_request(&request_str, 1024 * 1024) + .await + { + Ok((response_json, mut rx)) => { + // Write the initial response + let response: Value = serde_json::from_str(&response_json) + .map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))?; + Self::write_response(&stdout, &response).await?; + + // Forward any subscription notifications to stdout + let stdout_clone = Arc::clone(&stdout); + tokio::spawn(async move { + loop { + match rx.recv().await { + Some(notification) => { + match serde_json::from_str::(¬ification) { + Ok(notification_value) => { + if let Err(e) = + Self::write_response(&stdout_clone, ¬ification_value).await + { + error!("Failed to write notification: {}", e); + break; + } + } + Err(e) => { + error!("Failed to parse notification: {}", e); + } + } + } + None => break, + } + } + debug!("Notification stream ended"); + }); + } + Err(e) => { + error!("Failed to execute request: {}", e); + let id = request.get("id").cloned(); + let error_response = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32603, + "message": format!("Internal error: {}", e) + } + }); + Self::write_response(&stdout, &error_response).await?; + } + } + } + + debug!("STDIO transport stopped"); + Ok(()) + } + + /// Write a JSON-RPC response to stdout + async fn write_response( + writer: &Arc>, + response: &Value, + ) -> anyhow::Result<()> { + let json = serde_json::to_string(response)?; + trace!("Sending response: {}", json); + + let mut guard = writer.lock().await; + guard.write_all(json.as_bytes()).await?; + guard.write_all(b"\n").await?; + guard.flush().await?; + + Ok(()) + } +} diff --git a/crates/forge_jsonrpc/src/types/mod.rs b/crates/forge_jsonrpc/src/types/mod.rs new file mode 100644 index 0000000000..ecf6920a29 --- /dev/null +++ b/crates/forge_jsonrpc/src/types/mod.rs @@ -0,0 +1,856 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// JSON-RPC 2.0 Request wrapper +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: Option, + pub method: String, + pub params: T, +} + +/// JSON-RPC 2.0 Response wrapper +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// JSON-RPC 2.0 Error object +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +// ============================================================================ +// Event Types (mirrors forge_domain::Event hierarchy) +// ============================================================================ + +/// User prompt text wrapper +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct UserPrompt(String); + +impl From for UserPrompt { + fn from(p: forge_domain::UserPrompt) -> Self { + Self(p.to_string()) + } +} + +/// User command with parameters +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct UserCommand { + pub name: String, + pub template: String, + pub parameters: Vec, +} + +impl From for UserCommand { + fn from(c: forge_domain::UserCommand) -> Self { + Self { + name: c.name, + template: c.template.template, + parameters: c.parameters, + } + } +} + +/// Event value variants (Text or Command) +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum EventValue { + Text(UserPrompt), + Command(UserCommand), +} + +impl From for EventValue { + fn from(v: forge_domain::EventValue) -> Self { + match v { + forge_domain::EventValue::Text(p) => EventValue::Text(UserPrompt::from(p)), + forge_domain::EventValue::Command(c) => EventValue::Command(UserCommand::from(c)), + } + } +} + +/// File info for attachment content +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct FileInfo { + pub start_line: u64, + pub end_line: u64, + pub total_lines: u64, + pub content_hash: String, +} + +/// Directory entry for directory listings +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct DirectoryEntry { + pub path: String, + pub is_dir: bool, +} + +/// Image attachment content +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct Image { + pub url: String, + pub mime_type: String, +} + +/// Attachment content variants +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AttachmentContent { + Image(Image), + FileContent { content: String, info: FileInfo }, + DirectoryListing { entries: Vec }, +} + +/// File/directory attachment +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct Attachment { + pub content: AttachmentContent, + pub path: String, +} + +/// Chat event containing user input +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct Event { + pub id: String, + pub value: Option, + pub timestamp: String, + pub attachments: Vec, + pub additional_context: Option, +} + +impl From for Event { + fn from(e: forge_domain::Event) -> Self { + Self { + id: e.id, + value: e.value.map(EventValue::from), + timestamp: e.timestamp, + attachments: e + .attachments + .into_iter() + .map(|a| Attachment { + content: match a.content { + forge_domain::AttachmentContent::Image(img) => { + AttachmentContent::Image(Image { + url: img.url().to_string(), + mime_type: img.mime_type().to_string(), + }) + } + forge_domain::AttachmentContent::FileContent { content, info } => { + AttachmentContent::FileContent { + content, + info: FileInfo { + start_line: info.start_line, + end_line: info.end_line, + total_lines: info.total_lines, + content_hash: info.content_hash, + }, + } + } + forge_domain::AttachmentContent::DirectoryListing { entries } => { + AttachmentContent::DirectoryListing { + entries: entries + .into_iter() + .map(|e| DirectoryEntry { path: e.path, is_dir: e.is_dir }) + .collect(), + } + } + }, + path: a.path, + }) + .collect(), + additional_context: e.additional_context, + } + } +} + +impl TryFrom for forge_domain::Event { + type Error = anyhow::Error; + + fn try_from(e: Event) -> Result { + Ok(Self { + id: e.id, + value: e.value.map(|v| v.try_into()).transpose()?, + timestamp: e.timestamp, + attachments: e + .attachments + .into_iter() + .map(|a| -> anyhow::Result { + Ok(forge_domain::Attachment { + content: match a.content { + AttachmentContent::Image(img) => { + forge_domain::AttachmentContent::Image( + forge_domain::Image::new_base64(img.url, img.mime_type), + ) + } + AttachmentContent::FileContent { content, info } => { + forge_domain::AttachmentContent::FileContent { + content, + info: forge_domain::FileInfo { + start_line: info.start_line, + end_line: info.end_line, + total_lines: info.total_lines, + content_hash: info.content_hash, + }, + } + } + AttachmentContent::DirectoryListing { entries } => { + forge_domain::AttachmentContent::DirectoryListing { + entries: entries + .into_iter() + .map(|e| forge_domain::DirectoryEntry { + path: e.path, + is_dir: e.is_dir, + }) + .collect(), + } + } + }, + path: a.path, + }) + }) + .collect::, _>>()?, + additional_context: e.additional_context, + }) + } +} + +impl TryFrom for forge_domain::EventValue { + type Error = anyhow::Error; + + fn try_from(v: EventValue) -> Result { + match v { + EventValue::Text(p) => { + // UserPrompt has From impl via derive_more::From + let prompt: forge_domain::UserPrompt = p.0.into(); + Ok(forge_domain::EventValue::Text(prompt)) + } + EventValue::Command(c) => Ok(forge_domain::EventValue::Command( + forge_domain::UserCommand { + name: c.name, + template: c.template.into(), + parameters: c.parameters, + }, + )), + } + } +} + +// ============================================================================ +// Request Parameters +// ============================================================================ + +/// Simple chat input for JSON-RPC +/// This is a simplified version of Event for easier API usage +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ChatInput { + /// Message content as plain text + pub message: String, +} + +/// Request parameters for chat method (using simple input) +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ChatParams { + pub conversation_id: String, + pub message: String, +} + +/// Request parameters for conversation operations +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ConversationParams { + pub conversation_id: String, +} + +/// Request parameters for rename conversation +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct RenameConversationParams { + pub conversation_id: String, + pub title: String, +} + +/// Request parameters for shell command execution +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ShellCommandParams { + pub command: String, + #[serde(default)] + pub working_dir: Option, +} + +/// Request parameters for workspace operations +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct WorkspacePathParams { + pub path: String, +} + +/// Request parameters for workspace sync +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SyncWorkspaceParams { + pub path: String, +} + +/// Request parameters for workspace query +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct QueryWorkspaceParams { + pub path: String, + pub query: String, + #[serde(default)] + pub limit: Option, +} + +/// Model configuration for config operations +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] +pub struct ModelConfigDto { + pub provider_id: String, + pub model_id: String, +} + +/// Individual config operation types +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case", tag = "type")] +pub enum ConfigOperationDto { + /// Set the active session provider and model + SetSessionConfig { config: ModelConfigDto }, + /// Set the commit-message generation configuration + SetCommitConfig { config: Option }, + /// Set the shell-command suggestion configuration + SetSuggestConfig { config: ModelConfigDto }, + /// Set the reasoning effort level + SetReasoningEffort { effort: String }, +} + +impl ConfigOperationDto { + /// Convert DTO to domain ConfigOperation + pub fn into_domain(self) -> anyhow::Result { + use std::str::FromStr; + + match self { + ConfigOperationDto::SetSessionConfig { config } => { + let provider_id = forge_domain::ProviderId::from_str(&config.provider_id) + .map_err(|e| anyhow::anyhow!("Invalid provider_id: {}", e))?; + let model_id = forge_domain::ModelId::new(&config.model_id); + Ok(forge_domain::ConfigOperation::SetSessionConfig( + forge_domain::ModelConfig::new(provider_id, model_id), + )) + } + ConfigOperationDto::SetCommitConfig { config } => { + let model_config = match config { + Some(c) => { + let provider_id = forge_domain::ProviderId::from_str(&c.provider_id) + .map_err(|e| anyhow::anyhow!("Invalid provider_id: {}", e))?; + let model_id = forge_domain::ModelId::new(&c.model_id); + Some(forge_domain::ModelConfig::new(provider_id, model_id)) + } + None => None, + }; + Ok(forge_domain::ConfigOperation::SetCommitConfig(model_config)) + } + ConfigOperationDto::SetSuggestConfig { config } => { + let provider_id = forge_domain::ProviderId::from_str(&config.provider_id) + .map_err(|e| anyhow::anyhow!("Invalid provider_id: {}", e))?; + let model_id = forge_domain::ModelId::new(&config.model_id); + Ok(forge_domain::ConfigOperation::SetSuggestConfig( + forge_domain::ModelConfig::new(provider_id, model_id), + )) + } + ConfigOperationDto::SetReasoningEffort { effort } => { + let effort = match effort.as_str() { + "low" => forge_domain::Effort::Low, + "medium" => forge_domain::Effort::Medium, + "high" => forge_domain::Effort::High, + _ => return Err(anyhow::anyhow!("Invalid effort level: {}", effort)), + }; + Ok(forge_domain::ConfigOperation::SetReasoningEffort(effort)) + } + } + } +} + +/// Request parameters for config operations using typed DTOs +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ConfigParams { + /// List of config operations to apply + pub ops: Vec, +} + +/// Request parameters for MCP config operations +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct McpConfigParams { + #[serde(default)] + pub scope: Option, +} + +/// Request parameters for provider auth +/// Request parameters for initializing provider auth +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ProviderAuthParams { + pub provider_id: String, + pub method: String, +} + +/// Request parameters for completing provider auth +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct CompleteProviderAuthParams { + pub provider_id: String, + /// The auth flow type that was initiated + pub flow_type: String, + /// For API key flow: the API key value + #[serde(default)] + pub api_key: Option, + /// For API key flow: URL parameters to include + #[serde(default)] + pub url_params: Option>, + /// For Authorization code flow: the authorization code + #[serde(default)] + pub code: Option, + /// Timeout in seconds (default: 60) + #[serde(default)] + pub timeout_seconds: Option, +} + +/// Request parameters for setting active agent +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SetActiveAgentParams { + pub agent_id: String, +} + +/// Request parameters for commit +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct CommitParams { + #[serde(default)] + pub preview: bool, + #[serde(default)] + pub max_diff_size: Option, + #[serde(default)] + pub diff: Option, + #[serde(default)] + pub additional_context: Option, +} + +/// Request parameters for compact conversation +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct CompactConversationParams { + pub conversation_id: String, +} + +/// Request parameters for generate command +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct GenerateCommandParams { + pub prompt: String, +} + +/// Request parameters for generate data +/// Matches forge_domain::DataGenerationParameters +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct GenerateDataParams { + /// Path to input JSONL file for data generation + pub input: String, + /// Path to JSON schema file for LLM tool definition + pub schema: String, + /// Path to Handlebars template file for system prompt (optional) + #[serde(default)] + pub system_prompt: Option, + /// Path to Handlebars template file for user prompt (optional) + #[serde(default)] + pub user_prompt: Option, + /// Maximum number of concurrent LLM requests (default: 1) + #[serde(default)] + pub concurrency: Option, +} + +/// Request parameters for delete workspaces +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct DeleteWorkspacesParams { + pub workspace_ids: Vec, +} + +/// Request parameters for MCP auth +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct McpAuthParams { + pub server_url: String, +} + +/// Request parameters for MCP logout +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct McpLogoutParams { + #[serde(default)] + pub server_url: Option, +} + +/// Request parameters for MCP auth status +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct McpAuthStatusParams { + pub server_url: String, +} + +/// Request parameters for writing MCP config +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct WriteMcpConfigParams { + pub scope: String, + pub config: serde_json::Value, +} + +/// Request parameters for initializing workspace +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct InitWorkspaceParams { + pub path: String, +} + +// ============================================================================ +// Response DTOs (mirrors forge_domain types) +// ============================================================================ + +/// Model response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ModelResponse { + pub id: String, + pub name: Option, + pub provider: String, +} + +impl From for ModelResponse { + fn from(m: forge_domain::Model) -> Self { + Self { + id: m.id.to_string(), + name: m.name, + provider: "unknown".to_string(), // Model doesn't have provider in domain + } + } +} + +/// Agent response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct AgentResponse { + pub id: String, + pub name: String, + pub description: Option, +} + +impl From for AgentResponse { + fn from(a: forge_domain::Agent) -> Self { + Self { + id: a.id.to_string(), + name: a.title.unwrap_or_else(|| a.id.to_string()), + description: None, // Agent doesn't have description field in domain + } + } +} + +/// File discovery response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct FileResponse { + pub path: String, + pub is_dir: bool, +} + +impl From for FileResponse { + fn from(f: forge_domain::File) -> Self { + Self { path: f.path, is_dir: f.is_dir } + } +} + +/// Conversation response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ConversationResponse { + pub id: String, + pub title: Option, + pub created_at: String, + pub updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message_count: Option, +} + +impl From for ConversationResponse { + fn from(c: forge_domain::Conversation) -> Self { + Self { + id: c.id.to_string(), + title: c.title, + created_at: c.metadata.created_at.to_string(), + updated_at: c.metadata.updated_at.as_ref().map(|t| t.to_string()), + message_count: c.context.as_ref().map(|ctx| ctx.messages.len()), + } + } +} + +/// Command output response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct CommandOutputResponse { + pub stdout: String, + pub stderr: String, + pub exit_code: Option, +} + +impl From for CommandOutputResponse { + fn from(c: forge_domain::CommandOutput) -> Self { + Self { stdout: c.stdout, stderr: c.stderr, exit_code: c.exit_code } + } +} + +/// Workspace info response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct WorkspaceInfoResponse { + pub workspace_id: String, + pub working_dir: String, + pub node_count: Option, + pub relation_count: Option, + pub last_updated: Option, + pub created_at: String, +} + +impl From for WorkspaceInfoResponse { + fn from(w: forge_domain::WorkspaceInfo) -> Self { + Self { + workspace_id: w.workspace_id.to_string(), + working_dir: w.working_dir, + node_count: w.node_count, + relation_count: w.relation_count, + last_updated: w.last_updated.as_ref().map(|t| t.to_string()), + created_at: w.created_at.to_string(), + } + } +} + +/// File status response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct FileStatusResponse { + pub path: String, + pub status: String, +} + +impl From for FileStatusResponse { + fn from(f: forge_domain::FileStatus) -> Self { + Self { path: f.path, status: format!("{:?}", f.status) } + } +} + +/// Compaction result response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct CompactionResultResponse { + pub original_tokens: usize, + pub compacted_tokens: usize, + pub original_messages: usize, + pub compacted_messages: usize, +} + +impl From for CompactionResultResponse { + fn from(c: forge_domain::CompactionResult) -> Self { + Self { + original_tokens: c.original_tokens, + compacted_tokens: c.compacted_tokens, + original_messages: c.original_messages, + compacted_messages: c.compacted_messages, + } + } +} + +/// Stream message for subscription-based streaming +#[derive(Debug, Clone, Serialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum StreamMessage { + Chunk { data: serde_json::Value }, + Error { message: String }, + Complete, +} + +/// Provider response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProviderResponse { + pub id: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub api_key: Option, +} + +impl From for ProviderResponse { + fn from(p: forge_domain::AnyProvider) -> Self { + let id = p.id(); + let name = p + .response() + .map(|r| format!("{:?}", r)) + .unwrap_or_else(|| id.to_string()); + let api_key = match &p { + forge_domain::AnyProvider::Url(provider) => provider + .credential + .as_ref() + .map(|_| "configured".to_string()), + _ => None, + }; + Self { id: id.to_string(), name, api_key } + } +} + +/// User info response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct UserInfoResponse { + pub auth_provider_id: String, +} + +impl From for UserInfoResponse { + fn from(u: forge_app::User) -> Self { + Self { auth_provider_id: u.auth_provider_id.into_string() } + } +} + +/// User usage response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct UserUsageResponse { + pub plan_type: String, + pub current: u32, + pub limit: u32, + pub remaining: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub reset_in: Option, +} + +impl From for UserUsageResponse { + fn from(u: forge_app::UserUsage) -> Self { + Self { + plan_type: u.plan.r#type, + current: u.usage.current, + limit: u.usage.limit, + remaining: u.usage.remaining, + reset_in: u.usage.reset_in, + } + } +} + +/// Command response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct CommandResponse { + pub name: String, + pub description: String, +} + +impl From for CommandResponse { + fn from(c: forge_domain::Command) -> Self { + Self { name: c.name, description: c.description } + } +} + +/// Skill response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SkillResponse { + pub name: String, + pub path: Option, + pub command: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub resources: Option>, +} + +impl From for SkillResponse { + fn from(s: forge_domain::Skill) -> Self { + Self { + name: s.name, + path: s.path.map(|p| p.to_string_lossy().to_string()), + command: s.command, + description: s.description, + resources: Some( + s.resources + .into_iter() + .map(|r| r.to_string_lossy().to_string()) + .collect(), + ) + .filter(|r: &Vec| !r.is_empty()), + } + } +} + +/// Commit result response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct CommitResultResponse { + pub message: String, + pub has_staged_files: bool, +} + +impl From for CommitResultResponse { + fn from(c: forge_app::CommitResult) -> Self { + Self { message: c.message, has_staged_files: c.has_staged_files } + } +} + +/// Auth context request response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct AuthContextRequestResponse { + pub url: Option, + pub message: Option, +} + +// Note: AuthContextRequest is an enum (ApiKey, DeviceCode, Code) +// Implementing From would require pattern matching each variant +// For now, manual conversion is needed based on the specific variant + +/// Node response DTO for workspace query +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct NodeResponse { + pub node_id: String, + pub path: Option, + pub content: Option, + pub relevance: Option, + pub distance: Option, +} + +impl From for NodeResponse { + fn from(n: forge_domain::Node) -> Self { + Self { + node_id: n.node_id.to_string(), + path: None, // NodeData doesn't directly expose path, would need pattern matching + content: None, // NodeData content would need pattern matching + relevance: n.relevance, + distance: n.distance, + } + } +} + +/// Sync progress response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SyncProgressResponse { + pub processed_files: usize, + pub total_files: usize, + pub current_file: Option, +} + +/// Tools overview response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ToolsOverviewResponse { + pub enabled: Vec, + pub disabled: Vec, +} + +impl From for ToolsOverviewResponse { + fn from(t: forge_app::dto::ToolsOverview) -> Self { + Self { + enabled: t.system.into_iter().map(|s| s.name.to_string()).collect(), + disabled: vec![], // Not directly available in domain type + } + } +} + +/// Provider models response DTO +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ProviderModelsResponse { + pub provider_id: String, + pub provider_name: String, + pub models: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl From for ProviderModelsResponse { + fn from(p: forge_domain::ProviderModels) -> Self { + Self { + provider_id: p.provider_id.to_string(), + provider_name: p.provider_id.to_string(), // Use provider_id as name + models: p.models.into_iter().map(ModelResponse::from).collect(), + error: None, + } + } +} diff --git a/crates/forge_jsonrpc/tests/integration_tests.rs b/crates/forge_jsonrpc/tests/integration_tests.rs new file mode 100644 index 0000000000..654a3a197c --- /dev/null +++ b/crates/forge_jsonrpc/tests/integration_tests.rs @@ -0,0 +1,287 @@ +use std::io::{BufRead, Write}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use serde_json::{Value, json}; + +/// Helper to spawn the JSON-RPC server process +pub struct JsonRpcProcess { + child: Child, + stdin: std::process::ChildStdin, + stdout: std::process::ChildStdout, +} + +impl JsonRpcProcess { + /// Spawn the forge-jsonrpc binary + pub fn spawn() -> anyhow::Result { + let mut child = Command::new("cargo") + .args(["run", "--bin", "forge-jsonrpc", "--"]) + .current_dir("/Users/amit/code-forge") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + + let stdin = child.stdin.take().expect("Failed to get stdin"); + let stdout = child.stdout.take().expect("Failed to get stdout"); + + // Give the server time to start + std::thread::sleep(Duration::from_millis(500)); + + Ok(Self { child, stdin, stdout }) + } + + /// Send a JSON-RPC request and return the response + pub fn send_request(&mut self, request: Value) -> anyhow::Result { + let request_json = serde_json::to_string(&request)?; + writeln!(self.stdin, "{}", request_json)?; + self.stdin.flush()?; + + // Read response line + let mut reader = std::io::BufReader::new(&mut self.stdout); + let mut line = String::new(); + reader.read_line(&mut line)?; + + let response: Value = serde_json::from_str(&line)?; + Ok(response) + } + + /// Send a simple method call + pub fn call(&mut self, method: &str, params: Value) -> anyhow::Result { + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params + }); + self.send_request(request) + } + + /// Kill the process + pub fn kill(mut self) -> anyhow::Result<()> { + self.child.kill()?; + self.child.wait()?; + Ok(()) + } +} + +impl Drop for JsonRpcProcess { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_simple_method_call() { + // Note: This test requires the forge-jsonrpc binary to be built + // For now, we skip if the binary doesn't exist + let Ok(mut process) = JsonRpcProcess::spawn() else { + eprintln!("Skipping integration test - could not spawn forge-jsonrpc process"); + return; + }; + + // Give the server a moment to start + std::thread::sleep(Duration::from_millis(500)); + + // Test a simple method + let response = match process.call("environment", json!({})) { + Ok(resp) => resp, + Err(e) => { + eprintln!("Skipping integration test - failed to get response: {}", e); + return; + } + }; + + // Should have jsonrpc version + assert_eq!(response["jsonrpc"], "2.0"); + // Should have either result or error + assert!(response.get("result").is_some() || response.get("error").is_some()); + } + + #[test] + fn test_parse_error() { + let Ok(mut process) = JsonRpcProcess::spawn() else { + eprintln!("Skipping integration test - could not spawn forge-jsonrpc process"); + return; + }; + + // Give the server a moment to start + std::thread::sleep(Duration::from_millis(500)); + + // Send invalid JSON + if writeln!(process.stdin, "not valid json").is_err() { + eprintln!("Skipping integration test - could not write to stdin"); + return; + } + if process.stdin.flush().is_err() { + eprintln!("Skipping integration test - could not flush stdin"); + return; + } + + // Read response + let mut reader = std::io::BufReader::new(&mut process.stdout); + let mut line = String::new(); + if reader.read_line(&mut line).is_err() || line.is_empty() { + eprintln!("Skipping integration test - could not read response"); + return; + } + + let response: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + eprintln!("Skipping integration test - invalid JSON response: {}", e); + return; + } + }; + + // Should be a parse error + assert_eq!(response["jsonrpc"], "2.0"); + assert!(response.get("error").is_some()); + assert_eq!(response["error"]["code"], -32700); + } + + /// Test that subscription notifications are properly forwarded over stdio + /// This tests the fix for the critical issue where notifications weren't being + /// sent to stdout in stdio transport mode. + #[test] + fn test_subscription_notifications_over_stdio() { + let Ok(mut process) = JsonRpcProcess::spawn() else { + eprintln!("Skipping integration test - could not spawn forge-jsonrpc process"); + return; + }; + + // Give the server a moment to start + std::thread::sleep(Duration::from_millis(500)); + + // First create a conversation that we can use + let create_response = match process.call( + "create_conversation", + json!({ + "title": "Test conversation for streaming", + "working_dir": "/Users/amit/code-forge" + }), + ) { + Ok(resp) => resp, + Err(e) => { + eprintln!("Skipping streaming test - could not create conversation: {}", e); + return; + } + }; + + let conversation_id = match create_response["result"]["id"].as_str() { + Some(id) => id, + None => { + eprintln!("Skipping streaming test - no conversation ID in response: {:?}", create_response); + return; + } + }; + + // Subscribe to chat stream + let subscribe_request = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "chat.subscribe", + "params": { + "conversation_id": conversation_id, + "message": "Say hello" + } + }); + + let request_json = match serde_json::to_string(&subscribe_request) { + Ok(json) => json, + Err(e) => { + eprintln!("Skipping streaming test - failed to serialize request: {}", e); + return; + } + }; + + if writeln!(process.stdin, "{}", request_json).is_err() { + eprintln!("Skipping streaming test - could not write subscribe request"); + return; + } + if process.stdin.flush().is_err() { + eprintln!("Skipping streaming test - could not flush stdin"); + return; + } + + // Read the subscription confirmation response (immediate response with subscription ID) + let mut reader = std::io::BufReader::new(&mut process.stdout); + let mut line = String::new(); + + // The subscription response should come first + if reader.read_line(&mut line).is_err() || line.is_empty() { + eprintln!("Skipping streaming test - no subscription response"); + return; + } + + let subscription_response: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + eprintln!("Skipping streaming test - invalid subscription response: {}", e); + return; + } + }; + + // Verify the subscription was accepted (result should be null for successful subscription) + assert_eq!(subscription_response["jsonrpc"], "2.0"); + assert_eq!(subscription_response["id"], 2); + + // Now wait for notifications - we should receive chat.notification messages + let mut notification_count = 0; + let timeout = std::time::Instant::now() + Duration::from_secs(30); + + while std::time::Instant::now() < timeout && notification_count < 3 { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) => { + // EOF - stream closed + break; + } + Ok(_) => { + let notification: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(e) => { + eprintln!("Warning: failed to parse notification: {}", e); + continue; + } + }; + + // Check if this is a notification (no id field) + if notification.get("id").is_none() && notification.get("method").is_some() { + // This is a notification + let method = notification["method"].as_str().unwrap_or(""); + if method == "chat.notification" { + notification_count += 1; + } + } else if notification.get("result").is_some() { + // This could be the final response + break; + } + } + Err(_) => { + // Timeout or error + break; + } + } + } + + // We should have received at least some notifications (message, complete) + // Note: In a real scenario this would be more, but for testing purposes + // we just verify that the notification mechanism works + assert!( + notification_count > 0, + "Expected at least one chat.notification, got {}", + notification_count + ); + + eprintln!( + "Success: Received {} streaming notifications over stdio", + notification_count + ); + } +} diff --git a/crates/forge_jsonrpc/tests/unit_tests.rs b/crates/forge_jsonrpc/tests/unit_tests.rs new file mode 100644 index 0000000000..d8b7cda322 --- /dev/null +++ b/crates/forge_jsonrpc/tests/unit_tests.rs @@ -0,0 +1,150 @@ +#[cfg(test)] +mod tests { + use forge_api::API; + use forge_jsonrpc::test_utils::{MockAPI, create_test_server, create_test_server_with_mock}; + + #[test] + fn test_server_creation() { + let server = create_test_server(); + // Server should be created successfully + let _module = server.into_module(); + } + + #[test] + fn test_server_with_custom_mock() { + let mock = MockAPI { authenticated: true, ..Default::default() }; + let server = create_test_server_with_mock(mock); + let _module = server.into_module(); + } + + #[tokio::test] + async fn test_module_has_methods() { + let server = create_test_server(); + let module = server.into_module(); + + // Check that common methods are registered + let methods = module.method_names().collect::>(); + + assert!( + methods.contains(&"get_models"), + "get_models method should be registered" + ); + assert!( + methods.contains(&"get_agents"), + "get_agents method should be registered" + ); + assert!( + methods.contains(&"get_tools"), + "get_tools method should be registered" + ); + assert!( + methods.contains(&"discover"), + "discover method should be registered" + ); + assert!( + methods.contains(&"chat.stream"), + "chat.stream method should be registered" + ); + assert!( + methods.contains(&"get_conversations"), + "get_conversations method should be registered" + ); + assert!( + methods.contains(&"list_workspaces"), + "list_workspaces method should be registered" + ); + } + + #[test] + fn test_mock_api_default() { + let mock = MockAPI::default(); + assert!(!mock.authenticated); + assert!(mock.models.is_empty()); + assert!(mock.agents.is_empty()); + assert!(mock.conversations.is_empty()); + assert!(mock.workspaces.is_empty()); + } + + #[tokio::test] + async fn test_mock_api_discover() { + let mock = MockAPI::default(); + let files = mock.discover().await.unwrap(); + assert_eq!(files.len(), 2); + } + + #[tokio::test] + async fn test_mock_api_chat() { + let mock = MockAPI::default(); + use forge_domain::{ChatRequest, ConversationId, Event}; + + let request = ChatRequest { + event: Event::empty(), + conversation_id: ConversationId::generate(), + }; + let stream = mock.chat(request).await.unwrap(); + // Stream should be created + drop(stream); + } + + #[tokio::test] + async fn test_mock_api_conversations() { + let mock = MockAPI::default(); + let conversations = mock.get_conversations(None).await.unwrap(); + assert!(conversations.is_empty()); + } + + #[tokio::test] + async fn test_mock_api_shell_command() { + let mock = MockAPI::default(); + let output = mock + .execute_shell_command("echo test", std::path::PathBuf::from(".")) + .await + .unwrap(); + assert!(output.stdout.contains("Executed: echo test")); + } + + #[tokio::test] + async fn test_mock_api_is_authenticated() { + let mock_unauth = MockAPI::default(); + let is_auth = mock_unauth.is_authenticated().await.unwrap(); + assert!(!is_auth); + + let mock_auth = MockAPI { authenticated: true, ..Default::default() }; + let is_auth = mock_auth.is_authenticated().await.unwrap(); + assert!(is_auth); + } + + #[tokio::test] + async fn test_mock_api_user_info() { + let mock_unauth = MockAPI::default(); + let user_info = mock_unauth.user_info().await.unwrap(); + assert!(user_info.is_none()); + + let mock_auth = MockAPI { authenticated: true, ..Default::default() }; + let user_info = mock_auth.user_info().await.unwrap(); + assert!(user_info.is_some()); + } + + #[tokio::test] + async fn test_mock_api_commit() { + let mock = MockAPI::default(); + let result = mock.commit(false, None, None, None).await.unwrap(); + assert_eq!(result.message, "test commit"); + assert!(result.has_staged_files); + } + + #[tokio::test] + async fn test_mock_api_compact_conversation() { + let mock = MockAPI::default(); + use forge_domain::ConversationId; + + let result = mock + .compact_conversation(&ConversationId::generate()) + .await + .unwrap(); + assert_eq!(result.original_tokens, 1000); + assert_eq!(result.compacted_tokens, 500); + assert_eq!(result.original_messages, 20); + assert_eq!(result.compacted_messages, 10); + } +} diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index d3d4d472f8..dfb3b8cce3 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -8,6 +8,12 @@ rust-version.workspace = true name = "forge" path = "src/main.rs" +[[bin]] +name = "forge-jsonrpc" +path = "src/bin/forge-jsonrpc.rs" + +[features] +default = [] [dependencies] thiserror = { workspace = true } @@ -39,6 +45,7 @@ reedline.workspace = true nu-ansi-term.workspace = true nucleo.workspace = true tracing.workspace = true +tracing-subscriber.workspace = true chrono.workspace = true serde_json.workspace = true serde.workspace = true @@ -68,6 +75,7 @@ terminal_size = "0.4" rustls.workspace = true tempfile.workspace = true tiny_http.workspace = true +forge_jsonrpc.workspace = true [target.'cfg(windows)'.dependencies] enable-ansi-support.workspace = true diff --git a/crates/forge_main/src/bin/forge-jsonrpc.rs b/crates/forge_main/src/bin/forge-jsonrpc.rs new file mode 100644 index 0000000000..4c076574df --- /dev/null +++ b/crates/forge_main/src/bin/forge-jsonrpc.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use clap::Parser; +use forge_api::ForgeAPI; +use forge_config::ForgeConfig; +use forge_jsonrpc::JsonRpcServer; +use tracing::debug; +use url::Url; + +/// Forge JSON-RPC Server +/// +/// A JSON-RPC server for Forge that communicates over STDIO (stdin/stdout). +/// Uses newline-delimited JSON-RPC over stdio, suitable for programmatic integrations. +/// Note: This is standard JSON-RPC over stdio, not LSP (Language Server Protocol) +/// which uses Content-Length framing. +#[derive(Parser)] +#[command(name = "forge-jsonrpc")] +#[command(about = "JSON-RPC server for Forge (STDIO mode)")] +struct Cli { + /// Working directory + #[arg(short, long)] + directory: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize rustls crypto provider + let _ = rustls::crypto::ring::default_provider().install_default(); + + // Set up panic hook for better error reporting + std::panic::set_hook(Box::new(|info| { + let location = info.location().map(|l| format!("{}:{}", l.file(), l.line())); + if let Some(loc) = location { + tracing::error!("Panic occurred at {}: {:?}", loc, info.payload()); + } else { + tracing::error!("Panic occurred: {:?}", info.payload()); + } + })); + + // Initialize tracing for logging + let subscriber = tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter(tracing_subscriber::EnvFilter::new("info")) + .finish(); + let _ = tracing::subscriber::set_global_default(subscriber); + + debug!("Starting Forge JSON-RPC server (STDIO mode)"); + + let cli = Cli::parse(); + + // Read configuration + let config = + ForgeConfig::read().context("Failed to read Forge configuration from .forge.toml")?; + + let services_url: Url = config + .services_url + .parse() + .context("services_url in configuration must be a valid URL")?; + + let cwd = cli + .directory + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + + // Initialize the API + let api = Arc::new(ForgeAPI::init(cwd, config, services_url)); + + // Create and run the JSON-RPC server (STDIO mode only) + let server = JsonRpcServer::new(api); + server.run_stdio().await +} From fb58ad5eb4d313d42e12c9bb26c89d5bddb5fe59 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Thu, 9 Apr 2026 11:26:53 +0530 Subject: [PATCH 2/3] feat(jsonrpc): convert chat.stream to subscription-based streaming for stdio transport --- crates/forge_jsonrpc/src/error.rs | 52 ++-- crates/forge_jsonrpc/src/server.rs | 243 ++++++++++-------- crates/forge_jsonrpc/src/test_utils.rs | 1 - crates/forge_jsonrpc/src/transport/stdio.rs | 89 ++++--- .../forge_jsonrpc/tests/integration_tests.rs | 30 ++- crates/forge_main/Cargo.toml | 4 - crates/forge_main/src/bin/forge-jsonrpc.rs | 73 ------ crates/forge_main/src/cli.rs | 11 + crates/forge_main/src/main.rs | 30 ++- crates/forge_main/src/ui.rs | 6 + 10 files changed, 293 insertions(+), 246 deletions(-) delete mode 100644 crates/forge_main/src/bin/forge-jsonrpc.rs diff --git a/crates/forge_jsonrpc/src/error.rs b/crates/forge_jsonrpc/src/error.rs index e515f9b4c5..893fefec49 100644 --- a/crates/forge_jsonrpc/src/error.rs +++ b/crates/forge_jsonrpc/src/error.rs @@ -48,38 +48,38 @@ pub fn map_error(err: anyhow::Error) -> ErrorObjectOwned { /// Map domain errors to JSON-RPC errors fn map_domain_error(err: &forge_domain::Error) -> ErrorObjectOwned { match err { - forge_domain::Error::ConversationNotFound(_) | - forge_domain::Error::AgentUndefined(_) | - forge_domain::Error::WorkspaceNotFound | - forge_domain::Error::HeadAgentUndefined => { + forge_domain::Error::ConversationNotFound(_) + | forge_domain::Error::AgentUndefined(_) + | forge_domain::Error::WorkspaceNotFound + | forge_domain::Error::HeadAgentUndefined => { ErrorObject::owned(ErrorCode::NOT_FOUND, err.to_string(), None::<()>) } - forge_domain::Error::ProviderNotAvailable { .. } | - forge_domain::Error::EnvironmentVariableNotFound { .. } | - forge_domain::Error::AuthTokenNotFound => { + forge_domain::Error::ProviderNotAvailable { .. } + | forge_domain::Error::EnvironmentVariableNotFound { .. } + | forge_domain::Error::AuthTokenNotFound => { ErrorObject::owned(ErrorCode::UNAUTHORIZED, err.to_string(), None::<()>) } - forge_domain::Error::ConversationId(_) | - forge_domain::Error::ToolCallArgument { .. } | - forge_domain::Error::AgentCallArgument { .. } | - forge_domain::Error::ToolCallParse(_) | - forge_domain::Error::ToolCallMissingName | - forge_domain::Error::ToolCallMissingId | - forge_domain::Error::EToolCallArgument(_) | - forge_domain::Error::MissingAgentDescription(_) | - forge_domain::Error::MissingModel(_) | - forge_domain::Error::NoModelDefined(_) | - forge_domain::Error::NoDefaultSession => { + forge_domain::Error::ConversationId(_) + | forge_domain::Error::ToolCallArgument { .. } + | forge_domain::Error::AgentCallArgument { .. } + | forge_domain::Error::ToolCallParse(_) + | forge_domain::Error::ToolCallMissingName + | forge_domain::Error::ToolCallMissingId + | forge_domain::Error::EToolCallArgument(_) + | forge_domain::Error::MissingAgentDescription(_) + | forge_domain::Error::MissingModel(_) + | forge_domain::Error::NoModelDefined(_) + | forge_domain::Error::NoDefaultSession => { ErrorObject::owned(ErrorCode::VALIDATION_FAILED, err.to_string(), None::<()>) } - forge_domain::Error::MaxTurnsReached(_, _) | - forge_domain::Error::WorkspaceAlreadyInitialized(_) | - forge_domain::Error::SyncFailed { .. } | - forge_domain::Error::EmptyCompletion | - forge_domain::Error::VertexAiConfiguration { .. } | - forge_domain::Error::Retryable(_) | - forge_domain::Error::UnsupportedRole(_) | - forge_domain::Error::UndefinedVariable(_) => { + forge_domain::Error::MaxTurnsReached(_, _) + | forge_domain::Error::WorkspaceAlreadyInitialized(_) + | forge_domain::Error::SyncFailed { .. } + | forge_domain::Error::EmptyCompletion + | forge_domain::Error::VertexAiConfiguration { .. } + | forge_domain::Error::Retryable(_) + | forge_domain::Error::UnsupportedRole(_) + | forge_domain::Error::UndefinedVariable(_) => { ErrorObject::owned(ErrorCode::INTERNAL_ERROR, err.to_string(), None::<()>) } } diff --git a/crates/forge_jsonrpc/src/server.rs b/crates/forge_jsonrpc/src/server.rs index 1235a22529..719cde3a18 100644 --- a/crates/forge_jsonrpc/src/server.rs +++ b/crates/forge_jsonrpc/src/server.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use forge_api::API; use forge_domain::{ - AgentId, Conversation, ConversationId, DataGenerationParameters, McpConfig, - ProviderId, Scope, UserPrompt, + AgentId, Conversation, ConversationId, DataGenerationParameters, McpConfig, ProviderId, Scope, + UserPrompt, }; use futures::StreamExt; use jsonrpsee::types::ErrorObjectOwned; @@ -68,10 +68,8 @@ impl JsonRpcServer { let api = api.clone(); async move { let models = api.get_models().await.map_err(map_error)?; - let response: Vec = models - .into_iter() - .map(ModelResponse::from) - .collect(); + let response: Vec = + models.into_iter().map(ModelResponse::from).collect(); Ok::<_, ErrorObjectOwned>(to_json_response(response)?) } }) @@ -84,10 +82,8 @@ impl JsonRpcServer { let api = api.clone(); async move { let agents = api.get_agents().await.map_err(map_error)?; - let response: Vec = agents - .into_iter() - .map(AgentResponse::from) - .collect(); + let response: Vec = + agents.into_iter().map(AgentResponse::from).collect(); Ok::<_, ErrorObjectOwned>(to_json_response(response)?) } }) @@ -123,10 +119,8 @@ impl JsonRpcServer { let api = api.clone(); async move { let files = api.discover().await.map_err(map_error)?; - let response: Vec = files - .into_iter() - .map(FileResponse::from) - .collect(); + let response: Vec = + files.into_iter().map(FileResponse::from).collect(); Ok::<_, ErrorObjectOwned>(to_json_response(response)?) } }) @@ -139,10 +133,8 @@ impl JsonRpcServer { let api = api.clone(); async move { let providers = api.get_providers().await.map_err(map_error)?; - let response: Vec = providers - .into_iter() - .map(ProviderResponse::from) - .collect(); + let response: Vec = + providers.into_iter().map(ProviderResponse::from).collect(); Ok::<_, ErrorObjectOwned>(to_json_response(response)?) } }) @@ -492,90 +484,130 @@ impl JsonRpcServer { }) .expect("Failed to register compact_conversation"); - // chat.stream - method-based streaming for STDIO + // chat.stream - subscription-based streaming for real-time updates let api = self.api.clone(); self.module - .register_async_method("chat.stream", move |params, _, _| { - let api = api.clone(); - async move { - use forge_domain::ChatRequest; + .register_subscription( + "chat.stream", + "chat.notification", + "chat.stream.unsubscribe", + move |params, pending, _, _| { + let api = api.clone(); + async move { + use forge_domain::ChatRequest; - let params: ChatParams = params.parse()?; - let conversation_id = ConversationId::parse(¶ms.conversation_id) - .map_err(|e| { - ErrorObjectOwned::owned( - ErrorCode::INVALID_PARAMS, - format!("Invalid conversation_id: {}", e), - None::<()>, - ) - })?; + let params: ChatParams = params.parse()?; + let conversation_id = ConversationId::parse(¶ms.conversation_id) + .map_err(|e| { + ErrorObjectOwned::owned( + ErrorCode::INVALID_PARAMS, + format!("Invalid conversation_id: {}", e), + None::<()>, + ) + })?; - let event = forge_domain::Event::new(forge_domain::EventValue::text(params.message)); - let chat_req = ChatRequest::new(event, conversation_id); + let event = forge_domain::Event::new(forge_domain::EventValue::text( + params.message, + )); + let chat_req = ChatRequest::new(event, conversation_id); - let mut stream = api.chat(chat_req).await.map_err(map_error)?; - let mut messages = Vec::new(); + let stream = api.chat(chat_req).await.map_err(map_error)?; + let sink = pending.accept().await?; - while let Some(msg) = stream.next().await { - let msg = msg.map_err(map_error)?; - // Convert ChatResponse to a simple JSON representation - let json_msg = match msg { - forge_domain::ChatResponse::TaskMessage { content, .. } => { - json!({ - "type": "message", - "content": content.as_str() - }) - } - forge_domain::ChatResponse::TaskReasoning { content } => { - json!({ - "type": "reasoning", - "content": content - }) - } - forge_domain::ChatResponse::TaskComplete => { - json!({ - "type": "complete" - }) - } - forge_domain::ChatResponse::ToolCallStart { .. } => { - json!({ - "type": "tool_start" - }) - } - forge_domain::ChatResponse::ToolCallEnd(_) => { - json!({ - "type": "tool_end" - }) - } - forge_domain::ChatResponse::RetryAttempt { cause, .. } => { - json!({ - "type": "retry", - "cause": cause.as_str() - }) - } - forge_domain::ChatResponse::Interrupt { reason } => { - json!({ - "type": "interrupt", - "reason": format!("{:?}", reason) - }) + tokio::spawn(async move { + let mut stream = stream; + while let Some(result) = stream.next().await { + let msg = match result { + Ok(chat_msg) => { + let data = match chat_msg { + forge_domain::ChatResponse::TaskMessage { + content, + .. + } => { + json!({ + "type": "message", + "content": content.as_str() + }) + } + forge_domain::ChatResponse::TaskReasoning { + content, + } => { + json!({ + "type": "reasoning", + "content": content + }) + } + forge_domain::ChatResponse::TaskComplete => { + json!({ + "type": "complete" + }) + } + forge_domain::ChatResponse::ToolCallStart { + .. + } => { + json!({ + "type": "tool_start" + }) + } + forge_domain::ChatResponse::ToolCallEnd(_) => { + json!({ + "type": "tool_end" + }) + } + forge_domain::ChatResponse::RetryAttempt { + cause, + .. + } => { + json!({ + "type": "retry", + "cause": cause.as_str() + }) + } + forge_domain::ChatResponse::Interrupt { reason } => { + json!({ + "type": "interrupt", + "reason": format!("{:?}", reason) + }) + } + }; + StreamMessage::Chunk { data } + } + Err(e) => StreamMessage::Error { message: format!("{:#}", e) }, + }; + + let sub_msg = + SubscriptionMessage::from_json(&msg).unwrap_or_else(|_| { + SubscriptionMessage::from_json(&json!({"status": "error"})) + .expect("fallback message should never fail") + }); + if sink.send(sub_msg).await.is_err() { + debug!("Client disconnected from chat stream"); + break; + } } - }; - messages.push(json!({ - "type": "chunk", - "data": json_msg - })); - } - // Add a final complete marker - messages.push(json!({ - "type": "complete" - })); + let complete_msg = + SubscriptionMessage::from_json(&StreamMessage::Complete) + .unwrap_or_else(|_| { + SubscriptionMessage::from_json( + &json!({"status": "complete"}), + ) + .unwrap_or_else( + |_| { + SubscriptionMessage::from_json( + &json!({"done": true}), + ) + .expect("fallback message should never fail") + }, + ) + }); + let _ = sink.send(complete_msg).await; + }); - Ok::<_, ErrorObjectOwned>(json!({ - "stream": messages - })) - } - }) + Ok(()) + } + }, + ) .expect("Failed to register chat.stream"); } @@ -796,8 +828,17 @@ impl JsonRpcServer { let complete_msg = SubscriptionMessage::from_json(&StreamMessage::Complete) .unwrap_or_else(|_| { - SubscriptionMessage::from_json(&json!({"status": "complete"})) - .unwrap_or_else(|_| SubscriptionMessage::from_json(&json!({"done": true})).expect("fallback message should never fail")) + SubscriptionMessage::from_json( + &json!({"status": "complete"}), + ) + .unwrap_or_else( + |_| { + SubscriptionMessage::from_json( + &json!({"done": true}), + ) + .expect("fallback message should never fail") + }, + ) }); let _ = sink.send(complete_msg).await; }); @@ -1157,7 +1198,8 @@ impl JsonRpcServer { }) .expect("Failed to register init_provider_auth"); - // complete_provider_auth - Complete provider authentication and save credentials + // complete_provider_auth - Complete provider authentication and save + // credentials let api = self.api.clone(); self.module .register_async_method("complete_provider_auth", move |params, _, _| { @@ -1178,7 +1220,6 @@ impl JsonRpcServer { None::<()>, ) })?; - // Parse URL params if provided let url_params = params.url_params.unwrap_or_default(); let url_params_map: std::collections::HashMap<_, _> = url_params @@ -1187,14 +1228,12 @@ impl JsonRpcServer { (forge_domain::URLParam::from(k), forge_domain::URLParamValue::from(v)) }) .collect(); - // Build the API key request let api_key_request = forge_domain::ApiKeyRequest { required_params: vec![], existing_params: None, api_key: None, }; - forge_domain::AuthContextResponse::api_key( api_key_request, api_key, @@ -1219,7 +1258,6 @@ impl JsonRpcServer { None::<()>, ) })?; - // Authorization code flow also requires the original request return Err(ErrorObjectOwned::owned( -32603, @@ -1722,9 +1760,10 @@ fn get_method_list() -> Value { }, "chat": { "chat.stream": { - "description": "Stream chat messages for a conversation (method-based for STDIO)", + "description": "Subscribe to real-time chat message stream via notifications", "params": { "$ref": "ChatParams" }, - "returns": { "type": "object", "properties": { "stream": { "type": "array" } } } + "returns": { "type": "subscription" }, + "notifications": ["chat.notification"] } }, "conversations": { diff --git a/crates/forge_jsonrpc/src/test_utils.rs b/crates/forge_jsonrpc/src/test_utils.rs index 6887353c89..56a462ef02 100644 --- a/crates/forge_jsonrpc/src/test_utils.rs +++ b/crates/forge_jsonrpc/src/test_utils.rs @@ -20,7 +20,6 @@ pub struct MockAPI { pub authenticated: bool, } - #[async_trait::async_trait] impl forge_api::API for MockAPI { async fn discover(&self) -> anyhow::Result> { diff --git a/crates/forge_jsonrpc/src/transport/stdio.rs b/crates/forge_jsonrpc/src/transport/stdio.rs index 375b4c1314..8c53f8ca05 100644 --- a/crates/forge_jsonrpc/src/transport/stdio.rs +++ b/crates/forge_jsonrpc/src/transport/stdio.rs @@ -4,6 +4,7 @@ use jsonrpsee::server::RpcModule; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::sync::Mutex; +use tokio::task::JoinHandle; use tracing::{debug, error, trace}; /// STDIO transport for JSON-RPC @@ -29,10 +30,14 @@ impl StdioTransport { debug!("STDIO transport started (direct mode, no TCP)"); let mut lines = lines; + let mut pending_tasks: Vec> = Vec::new(); while let Ok(Some(line)) = lines.next_line().await { trace!("Received line: {}", line); + // Clean up completed tasks + pending_tasks.retain(|h| !h.is_finished()); + // Parse the JSON-RPC request let request: Value = match serde_json::from_str(&line) { Ok(req) => req, @@ -53,28 +58,40 @@ impl StdioTransport { // Execute the request let request_str = serde_json::to_string(&request)?; + let module = self.module.clone(); + let stdout_clone = Arc::clone(&stdout); - match self - .module - .raw_json_request(&request_str, 1024 * 1024) - .await - { - Ok((response_json, mut rx)) => { - // Write the initial response - let response: Value = serde_json::from_str(&response_json) - .map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))?; - Self::write_response(&stdout, &response).await?; - - // Forward any subscription notifications to stdout - let stdout_clone = Arc::clone(&stdout); - tokio::spawn(async move { + // Spawn the entire request handling so stdin can continue + // reading (or reach EOF) without blocking on the response. + let handle = tokio::spawn(async move { + match module.raw_json_request(&request_str, 1024 * 1024).await { + Ok((response_json, mut rx)) => { + // Write the initial response + match serde_json::from_str::(&response_json) { + Ok(response) => { + if let Err(e) = Self::write_response(&stdout_clone, &response).await + { + error!("Failed to write response: {}", e); + return; + } + } + Err(e) => { + error!("Failed to parse response: {}", e); + return; + } + } + + // Forward any subscription notifications to stdout loop { match rx.recv().await { Some(notification) => { match serde_json::from_str::(¬ification) { Ok(notification_value) => { - if let Err(e) = - Self::write_response(&stdout_clone, ¬ification_value).await + if let Err(e) = Self::write_response( + &stdout_clone, + ¬ification_value, + ) + .await { error!("Failed to write notification: {}", e); break; @@ -89,22 +106,32 @@ impl StdioTransport { } } debug!("Notification stream ended"); - }); - } - Err(e) => { - error!("Failed to execute request: {}", e); - let id = request.get("id").cloned(); - let error_response = serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32603, - "message": format!("Internal error: {}", e) - } - }); - Self::write_response(&stdout, &error_response).await?; + } + Err(e) => { + error!("Failed to execute request: {}", e); + let error_response = serde_json::json!({ + "jsonrpc": "2.0", + "id": null, + "error": { + "code": -32603, + "message": format!("Internal error: {}", e) + } + }); + let _ = Self::write_response(&stdout_clone, &error_response).await; + } } - } + }); + pending_tasks.push(handle); + } + + // Wait for all in-flight requests and their notification + // streams to finish before exiting. + debug!( + "STDIO stdin closed, waiting for {} pending task(s)", + pending_tasks.len() + ); + for handle in pending_tasks { + let _ = handle.await; } debug!("STDIO transport stopped"); diff --git a/crates/forge_jsonrpc/tests/integration_tests.rs b/crates/forge_jsonrpc/tests/integration_tests.rs index 654a3a197c..6299587fe6 100644 --- a/crates/forge_jsonrpc/tests/integration_tests.rs +++ b/crates/forge_jsonrpc/tests/integration_tests.rs @@ -146,8 +146,8 @@ mod tests { } /// Test that subscription notifications are properly forwarded over stdio - /// This tests the fix for the critical issue where notifications weren't being - /// sent to stdout in stdio transport mode. + /// This tests the fix for the critical issue where notifications weren't + /// being sent to stdout in stdio transport mode. #[test] fn test_subscription_notifications_over_stdio() { let Ok(mut process) = JsonRpcProcess::spawn() else { @@ -168,7 +168,10 @@ mod tests { ) { Ok(resp) => resp, Err(e) => { - eprintln!("Skipping streaming test - could not create conversation: {}", e); + eprintln!( + "Skipping streaming test - could not create conversation: {}", + e + ); return; } }; @@ -176,7 +179,10 @@ mod tests { let conversation_id = match create_response["result"]["id"].as_str() { Some(id) => id, None => { - eprintln!("Skipping streaming test - no conversation ID in response: {:?}", create_response); + eprintln!( + "Skipping streaming test - no conversation ID in response: {:?}", + create_response + ); return; } }; @@ -195,7 +201,10 @@ mod tests { let request_json = match serde_json::to_string(&subscribe_request) { Ok(json) => json, Err(e) => { - eprintln!("Skipping streaming test - failed to serialize request: {}", e); + eprintln!( + "Skipping streaming test - failed to serialize request: {}", + e + ); return; } }; @@ -209,7 +218,8 @@ mod tests { return; } - // Read the subscription confirmation response (immediate response with subscription ID) + // Read the subscription confirmation response (immediate response with + // subscription ID) let mut reader = std::io::BufReader::new(&mut process.stdout); let mut line = String::new(); @@ -222,12 +232,16 @@ mod tests { let subscription_response: Value = match serde_json::from_str(&line) { Ok(v) => v, Err(e) => { - eprintln!("Skipping streaming test - invalid subscription response: {}", e); + eprintln!( + "Skipping streaming test - invalid subscription response: {}", + e + ); return; } }; - // Verify the subscription was accepted (result should be null for successful subscription) + // Verify the subscription was accepted (result should be null for successful + // subscription) assert_eq!(subscription_response["jsonrpc"], "2.0"); assert_eq!(subscription_response["id"], 2); diff --git a/crates/forge_main/Cargo.toml b/crates/forge_main/Cargo.toml index dfb3b8cce3..800243e79d 100644 --- a/crates/forge_main/Cargo.toml +++ b/crates/forge_main/Cargo.toml @@ -8,10 +8,6 @@ rust-version.workspace = true name = "forge" path = "src/main.rs" -[[bin]] -name = "forge-jsonrpc" -path = "src/bin/forge-jsonrpc.rs" - [features] default = [] diff --git a/crates/forge_main/src/bin/forge-jsonrpc.rs b/crates/forge_main/src/bin/forge-jsonrpc.rs deleted file mode 100644 index 4c076574df..0000000000 --- a/crates/forge_main/src/bin/forge-jsonrpc.rs +++ /dev/null @@ -1,73 +0,0 @@ -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::{Context, Result}; -use clap::Parser; -use forge_api::ForgeAPI; -use forge_config::ForgeConfig; -use forge_jsonrpc::JsonRpcServer; -use tracing::debug; -use url::Url; - -/// Forge JSON-RPC Server -/// -/// A JSON-RPC server for Forge that communicates over STDIO (stdin/stdout). -/// Uses newline-delimited JSON-RPC over stdio, suitable for programmatic integrations. -/// Note: This is standard JSON-RPC over stdio, not LSP (Language Server Protocol) -/// which uses Content-Length framing. -#[derive(Parser)] -#[command(name = "forge-jsonrpc")] -#[command(about = "JSON-RPC server for Forge (STDIO mode)")] -struct Cli { - /// Working directory - #[arg(short, long)] - directory: Option, -} - -#[tokio::main] -async fn main() -> Result<()> { - // Initialize rustls crypto provider - let _ = rustls::crypto::ring::default_provider().install_default(); - - // Set up panic hook for better error reporting - std::panic::set_hook(Box::new(|info| { - let location = info.location().map(|l| format!("{}:{}", l.file(), l.line())); - if let Some(loc) = location { - tracing::error!("Panic occurred at {}: {:?}", loc, info.payload()); - } else { - tracing::error!("Panic occurred: {:?}", info.payload()); - } - })); - - // Initialize tracing for logging - let subscriber = tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .with_env_filter(tracing_subscriber::EnvFilter::new("info")) - .finish(); - let _ = tracing::subscriber::set_global_default(subscriber); - - debug!("Starting Forge JSON-RPC server (STDIO mode)"); - - let cli = Cli::parse(); - - // Read configuration - let config = - ForgeConfig::read().context("Failed to read Forge configuration from .forge.toml")?; - - let services_url: Url = config - .services_url - .parse() - .context("services_url in configuration must be a valid URL")?; - - let cwd = cli - .directory - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| PathBuf::from(".")); - - // Initialize the API - let api = Arc::new(ForgeAPI::init(cwd, config, services_url)); - - // Create and run the JSON-RPC server (STDIO mode only) - let server = JsonRpcServer::new(api); - server.run_stdio().await -} diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index 01d5b56f77..a393d9deea 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -148,6 +148,17 @@ pub enum TopLevelCommand { /// Run diagnostics on shell environment (alias for `zsh doctor`). Doctor, + + /// Start JSON-RPC server over STDIO. + JsonRpc(JsonRpcArgs), +} + +/// Arguments for the JSON-RPC server command. +#[derive(Parser, Debug, Clone)] +pub struct JsonRpcArgs { + /// Working directory for the JSON-RPC server. + #[arg(short, long)] + pub directory: Option, } /// Command group for custom command management. diff --git a/crates/forge_main/src/main.rs b/crates/forge_main/src/main.rs index b5d4748100..6a0785a902 100644 --- a/crates/forge_main/src/main.rs +++ b/crates/forge_main/src/main.rs @@ -1,13 +1,15 @@ use std::io::Read; use std::panic; use std::path::PathBuf; +use std::sync::Arc; use anyhow::{Context, Result}; use clap::Parser; use forge_api::ForgeAPI; use forge_config::ForgeConfig; use forge_domain::TitleFormat; -use forge_main::{Cli, Sandbox, TitleDisplayExt, UI, tracker}; +use forge_jsonrpc::JsonRpcServer; +use forge_main::{Cli, Sandbox, TitleDisplayExt, TopLevelCommand, UI, tracker}; use url::Url; /// Enables ENABLE_VIRTUAL_TERMINAL_PROCESSING on the stdout console handle. @@ -91,6 +93,32 @@ async fn run() -> Result<()> { // Initialize and run the UI let mut cli = Cli::parse(); + // Handle JSON-RPC subcommand early (before UI setup) + if let Some(TopLevelCommand::JsonRpc(args)) = &cli.subcommands { + // Read configuration + let config = + ForgeConfig::read().context("Failed to read Forge configuration from .forge.toml")?; + + // Parse services_url from config + let services_url: Url = config + .services_url + .parse() + .context("services_url in configuration must be a valid URL")?; + + let cwd = args + .directory + .clone() + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + + // Initialize the API + let api = Arc::new(ForgeAPI::init(cwd, config, services_url)); + + // Create and run the JSON-RPC server (STDIO mode only) + let server = JsonRpcServer::new(api); + return server.run_stdio().await; + } + // Check if there's piped input if !atty::is(atty::Stream::Stdin) { let mut stdin_content = String::new(); diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index 5ba5de69e4..b74517738d 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -699,6 +699,12 @@ impl A + Send + Sync> UI self.on_zsh_doctor().await?; return Ok(()); } + // JSON-RPC is handled in main.rs before UI initialization + TopLevelCommand::JsonRpc(_) => { + unreachable!( + "JSON-RPC command should be handled in main.rs before UI initialization" + ) + } } Ok(()) } From e1ffdfc9c12c50759b83e2125132d36b2e519539 Mon Sep 17 00:00:00 2001 From: Amit Singh Date: Thu, 9 Apr 2026 11:29:31 +0530 Subject: [PATCH 3/3] refactor(jsonrpc): simplify response handling in JsonRpcServer methods --- crates/forge_jsonrpc/src/server.rs | 52 +++++++++++++++--------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/crates/forge_jsonrpc/src/server.rs b/crates/forge_jsonrpc/src/server.rs index 719cde3a18..f50305b5a3 100644 --- a/crates/forge_jsonrpc/src/server.rs +++ b/crates/forge_jsonrpc/src/server.rs @@ -70,7 +70,7 @@ impl JsonRpcServer { let models = api.get_models().await.map_err(map_error)?; let response: Vec = models.into_iter().map(ModelResponse::from).collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_models"); @@ -84,7 +84,7 @@ impl JsonRpcServer { let agents = api.get_agents().await.map_err(map_error)?; let response: Vec = agents.into_iter().map(AgentResponse::from).collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_agents"); @@ -107,7 +107,7 @@ impl JsonRpcServer { } let response = ToolsOverviewResponse { enabled: all_tools, disabled: Vec::new() }; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_tools"); @@ -121,7 +121,7 @@ impl JsonRpcServer { let files = api.discover().await.map_err(map_error)?; let response: Vec = files.into_iter().map(FileResponse::from).collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register discover"); @@ -135,7 +135,7 @@ impl JsonRpcServer { let providers = api.get_providers().await.map_err(map_error)?; let response: Vec = providers.into_iter().map(ProviderResponse::from).collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_providers"); @@ -151,7 +151,7 @@ impl JsonRpcServer { .into_iter() .map(ProviderModelsResponse::from) .collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_all_provider_models"); @@ -175,7 +175,7 @@ impl JsonRpcServer { api_key: None, }; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_provider"); @@ -197,7 +197,7 @@ impl JsonRpcServer { api_key: None, }; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_agent_provider"); @@ -216,7 +216,7 @@ impl JsonRpcServer { api_key: None, }; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_default_provider"); @@ -314,7 +314,7 @@ impl JsonRpcServer { message_count: c.context.as_ref().map(|ctx| ctx.messages.len()), }) .collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_conversations"); @@ -446,7 +446,7 @@ impl JsonRpcServer { message_count: c.context.as_ref().map(|ctx| ctx.messages.len()), }); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register last_conversation"); @@ -479,7 +479,7 @@ impl JsonRpcServer { compacted_messages: result.compacted_messages, }; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register compact_conversation"); @@ -633,7 +633,7 @@ impl JsonRpcServer { created_at: w.created_at.to_rfc3339(), }) .collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register list_workspaces"); @@ -658,7 +658,7 @@ impl JsonRpcServer { created_at: w.created_at.to_rfc3339(), }); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_workspace_info"); @@ -708,7 +708,7 @@ impl JsonRpcServer { status: format!("{:?}", s.status), }) .collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_workspace_status"); @@ -897,7 +897,7 @@ impl JsonRpcServer { }) .collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register query_workspace"); @@ -925,7 +925,7 @@ impl JsonRpcServer { .await .map_err(map_error)?; let response = config; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register read_mcp_config"); @@ -1116,7 +1116,7 @@ impl JsonRpcServer { auth_provider_id: u.auth_provider_id.into_string(), }); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register user_info"); @@ -1137,7 +1137,7 @@ impl JsonRpcServer { reset_in: u.usage.reset_in, }); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register user_usage"); @@ -1193,7 +1193,7 @@ impl JsonRpcServer { }, }; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register init_provider_auth"); @@ -1310,7 +1310,7 @@ impl JsonRpcServer { async move { let auth = api.create_auth_credentials().await.map_err(map_error)?; let response = auth; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register create_auth_credentials"); @@ -1405,7 +1405,7 @@ impl JsonRpcServer { exit_code: output.exit_code, }; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register execute_shell_command"); @@ -1451,7 +1451,7 @@ impl JsonRpcServer { .into_iter() .map(|c| CommandResponse { name: c.name, description: c.description }) .collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_commands"); @@ -1479,7 +1479,7 @@ impl JsonRpcServer { .filter(|r: &Vec| !r.is_empty()), }) .collect(); - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register get_skills"); @@ -1522,7 +1522,7 @@ impl JsonRpcServer { has_staged_files: result.has_staged_files, }; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) } }) .expect("Failed to register commit"); @@ -1548,7 +1548,7 @@ impl JsonRpcServer { .register_method("environment", move |_, _, _| { let env = api.environment(); let response = env; - Ok::<_, ErrorObjectOwned>(to_json_response(response)?) + to_json_response(response) }) .expect("Failed to register environment");