diff --git a/Cargo.lock b/Cargo.lock index 8b9f2e6..cd35ceb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -585,6 +596,30 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "bs58" version = "0.5.1" @@ -606,6 +641,28 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -1207,7 +1264,7 @@ dependencies = [ [[package]] name = "endpoint-libs" -version = "2.1.4" +version = "2.1.5" dependencies = [ "alloy-primitives", "async-trait", @@ -1225,6 +1282,7 @@ dependencies = [ "http-body-util", "httpdate", "hyper", + "hyper-rustls", "hyper-util", "itertools 0.12.1", "lazy_static", @@ -1239,6 +1297,7 @@ dependencies = [ "regex", "reqwest", "rev_lines", + "rust_decimal", "rustls", "secrecy", "serde", @@ -1578,6 +1637,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -2765,6 +2827,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3029,6 +3111,15 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -3105,6 +3196,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rlp" version = "0.5.2" @@ -3150,6 +3270,23 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" +[[package]] +name = "rust_decimal" +version = "1.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.6", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3292,6 +3429,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "sec1" version = "0.7.3" @@ -3514,6 +3657,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.2" @@ -4125,7 +4274,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e4e430251f5ca8f86ea941286923dc2e91be7ca95fec777d75ee8ae1c493f64" dependencies = [ - "ahash", + "ahash 0.8.12", "dashmap", "tokio", "tracing", @@ -4354,6 +4503,7 @@ dependencies = [ "cfg-if", "once_cell", "rustversion", + "serde", "wasm-bindgen-macro", "wasm-bindgen-shared", ] diff --git a/Cargo.toml b/Cargo.toml index c0c504f..1ee5c69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "endpoint-libs" -version = "2.1.4" +version = "2.1.5" edition = "2024" authors = ["Veon "] description = "Launch MCP services fast: describe endpoints once in RON, and endpoint-gen generates the Rust models, docs and MCP tool schemas that this crate serves over WebSocket RPC, with roles and typed errors built in." @@ -26,9 +26,35 @@ full = [ "ws-http1", "ws-tls12", ] -types = [] +types = [ + "dep:serde", + "dep:serde_json", + "dep:derive_more", + "dep:convert_case", + "dep:itertools", + "dep:clap", + "dep:tracing", + "dep:tracing-subscriber", + "dep:chrono", + "dep:urlencoding", + "dep:hex", + "dep:rust_decimal", + "dep:tracing-appender", + "dep:serde_path_to_error", + "dep:alloy-primitives", + "dep:hyper-rustls", + "dep:opentelemetry", + "dep:opentelemetry_sdk", + "dep:opentelemetry-otlp", + "dep:opentelemetry-semantic-conventions", + "dep:tracing-opentelemetry", + "dep:opentelemetry-appender-tracing", + "dep:tonic", +] +wire-core = ["dep:async-trait", "dep:serde", "dep:serde_json"] ws-core = [ # Shared WS infrastructure (WsMessage, server, session, traits, etc.) + "wire-core", "types", "signal", "dep:parking_lot", @@ -40,9 +66,10 @@ ws-core = [ framed-transport = [ # Length-delimited WireMessage framing over any byte stream (Unix sockets, # named pipes, inherited socketpairs). No WebSocket, no TLS, no HTTP. - "ws-core", + "wire-core", "dep:tokio-util", ] +agent-control = ["framed-transport"] ws-client = [ # WS client (WsClient, WsClientBuilder) - standalone "ws-core", @@ -106,29 +133,30 @@ ws-http1 = ["ws", "hyper/http1"] ws-tls12 = ["ws", "rustls/tls12", "tokio-rustls/tls12"] [dependencies] -serde = { version = "1.0", features = ["derive"] } -serde_json = { version = "1.0", features = ["raw_value"] } +serde = { version = "1.0", features = ["derive"], optional = true } +serde_json = { version = "1.0", features = ["raw_value"], optional = true } eyre = "0.6" -derive_more = { version = "2.1.1", features = ["display"] } -convert_case = "0.6" -itertools = "0.12" -clap = { version = "4.5", features = ["derive", "env"] } +derive_more = { version = "2.1.1", features = ["display"], optional = true } +convert_case = { version = "0.6", optional = true } +itertools = { version = "0.12", optional = true } +clap = { version = "4.5", features = ["derive", "env"], optional = true } futures = "0.3" -tracing = "0.1" +tracing = { version = "0.1", optional = true } tracing-subscriber = { version = "0.3", features = [ "env-filter", "std", "fmt", -] } +], optional = true } bytes = "1.7" -chrono = {version = "0.4.44", default-features = false, features = ["oldtime", "std", "now"]} -urlencoding = "2.1" -hex = "0.4" -tracing-appender = "0.2" -serde_path_to_error = "0.1" +chrono = {version = "0.4.44", default-features = false, features = ["oldtime", "std", "now"], optional = true} +urlencoding = { version = "2.1", optional = true } +hex = { version = "0.4", optional = true } +rust_decimal = { version = "1.36", optional = true } +tracing-appender = { version = "0.2", optional = true } +serde_path_to_error = { version = "0.1", optional = true } alloy-primitives = { version = "1.3.0", features = [ "std", -], default-features = false } +], default-features = false, optional = true } # Optional dependencies tokio-tungstenite = { version = "0.29.0", default-features = false, features = [ @@ -138,7 +166,7 @@ tokio-tungstenite = { version = "0.29.0", default-features = false, features = [ tokio-rustls = { version = "0.26", optional = true, default-features = false, features = ["ring", "logging"] } rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "logging", "std"] } tokio = { version = "1.39", features = ["full"] } -tokio-util = { version = "0.7", optional = true } +tokio-util = { version = "0.7", features = ["codec"], optional = true } tokio-cron-scheduler = { version = "0.11", optional = true } tokio-postgres = { version = "0.7", optional = true } postgres-from-row = { version = "0.5", optional = true } @@ -164,15 +192,17 @@ httpdate = { version = "1.0", optional = true } tracing-throttle = { version = "0.4", features = ["async"], optional = true } webpki-roots = { version = "1.0.7", optional = true } crossfire = { version = "3.1", features = ["tokio"], optional = true } +# Force bundled CA roots for hyper-rustls (used by instant-acme inside cert-provider) +hyper-rustls = { version = "0.27.9", default-features = false, features = ["webpki-tokio"], optional = true } # OpenTelemetry dependencies (default feature) -opentelemetry = { version = "0.31", features = ["logs"] } -opentelemetry_sdk = { version = "0.31", features = ["rt-tokio", "logs"] } -opentelemetry-otlp = { version = "0.31", features = ["trace", "logs", "http-proto"] } -opentelemetry-semantic-conventions = "0.31" -tracing-opentelemetry = "0.32" -opentelemetry-appender-tracing = "0.31" +opentelemetry = { version = "0.31", features = ["logs"], optional = true } +opentelemetry_sdk = { version = "0.31", features = ["rt-tokio", "logs"], optional = true } +opentelemetry-otlp = { version = "0.31", features = ["trace", "logs", "http-proto"], optional = true } +opentelemetry-semantic-conventions = { version = "0.31", optional = true } +tracing-opentelemetry = { version = "0.32", optional = true } +opentelemetry-appender-tracing = { version = "0.31", optional = true } # Direct deps for header configuration -tonic = "0.14" +tonic = { version = "0.14", optional = true } [dev-dependencies] tempfile = "3.19" diff --git a/README.md b/README.md index 6b7620d..016b220 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,14 @@ no backend, no TLS and no HTTP. Everything the other `ws-*` features build on. ` and `WsClient::from_stream` are available here too, so a sidecar speaking only a local transport does not compile a TLS/WebSocket stack it never uses. +### `agent-control` + +The minimal built-in local wire surface for always-on application control. It enables +`WireMessage`, `TransportStream`, and length-delimited `framed_json` without the endpoint +server, WebSocket, HTTP, TLS, database, scheduler, or diagnostics layers. Agent-control +messages use the built-in `mcp_wire` JSON-RPC envelopes; application-specific generated +endpoints define the semantic inspect/action/lifecycle tools. + ### `ws-client` The connecting half: `WsClient::new` (TCP/TLS), `WsClientBuilder` and the connect helpers. diff --git a/src/libs.rs b/src/libs.rs index d40f9fb..f4f7bb4 100644 --- a/src/libs.rs +++ b/src/libs.rs @@ -1,14 +1,18 @@ +#[cfg(feature = "types")] pub mod log; #[cfg(feature = "log_reader")] pub mod log_reader; +#[cfg(feature = "types")] pub mod peer; #[cfg(feature = "scheduler")] pub mod scheduler; #[cfg(feature = "signal")] pub mod signal; +#[cfg(feature = "types")] pub mod types; +#[cfg(feature = "types")] pub mod utils; -#[cfg(feature = "ws-core")] +#[cfg(any(feature = "ws-core", feature = "wire-core"))] pub mod ws; #[cfg(feature = "ws-core")] @@ -17,19 +21,26 @@ pub use ws::handler; pub use ws::toolbox; #[deprecated] +#[cfg(feature = "types")] pub mod config; #[deprecated] #[cfg(feature = "database")] pub mod database; #[deprecated] +#[cfg(feature = "types")] pub mod datatable; #[deprecated] +#[cfg(feature = "types")] pub mod deserializer_wrapper; +#[cfg(feature = "types")] pub mod error_code; #[deprecated] +#[cfg(feature = "types")] pub mod warn; #[deprecated] +#[cfg(feature = "types")] pub const DEFAULT_LIMIT: i32 = 20; #[deprecated] +#[cfg(feature = "types")] pub const DEFAULT_OFFSET: i32 = 0; diff --git a/src/libs/ws.rs b/src/libs/ws.rs index 2ceb418..f7baf7e 100644 --- a/src/libs/ws.rs +++ b/src/libs/ws.rs @@ -1,39 +1,62 @@ +#[cfg(feature = "ws-core")] mod basics; +#[cfg(feature = "ws-core")] mod conn; +#[cfg(feature = "ws-core")] pub mod handler; +#[cfg(feature = "ws-core")] mod headers; +#[cfg(feature = "ws-core")] pub mod hooks; +#[cfg(feature = "ws-core")] mod listener; +#[cfg(feature = "ws-core")] pub mod mcp; +pub mod mcp_wire; mod message; +#[cfg(feature = "ws-core")] mod push; +#[cfg(feature = "ws-core")] mod server; +#[cfg(feature = "ws-core")] mod session; +#[cfg(feature = "ws-core")] mod subs; #[cfg(feature = "ws")] mod tls; +#[cfg(feature = "ws-core")] pub mod toolbox; mod traits; pub mod transport; +#[cfg(feature = "ws-core")] mod client; #[cfg(any(feature = "ws", feature = "ws-client"))] pub(crate) mod tungstenite; +#[cfg(feature = "ws-core")] pub use basics::*; +#[cfg(feature = "ws-core")] pub use conn::*; +#[cfg(feature = "ws-core")] pub use headers::*; +#[cfg(feature = "ws-core")] pub use hooks::*; +#[cfg(feature = "ws-core")] pub use listener::*; pub use message::*; +#[cfg(feature = "ws-core")] pub use server::*; +#[cfg(feature = "ws-core")] pub use session::*; +#[cfg(feature = "ws-core")] pub use subs::*; #[cfg(feature = "ws")] pub use tls::*; pub use traits::*; pub use transport::*; +#[cfg(feature = "ws-core")] pub use client::*; #[cfg(feature = "ws")] pub use tungstenite::*; diff --git a/src/libs/ws/mcp_wire.rs b/src/libs/ws/mcp_wire.rs new file mode 100644 index 0000000..aa4b417 --- /dev/null +++ b/src/libs/ws/mcp_wire.rs @@ -0,0 +1,162 @@ +//! Minimal JSON-RPC 2.0 wire types shared by MCP-compatible local protocols. +//! +//! The full MCP tool registry and endpoint dispatcher remain behind `ws-core`. +//! This module belongs to `wire-core`, so local agent-control clients can speak +//! MCP-compatible frames without compiling the server, HTTP, TLS, or schema stack. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const JSONRPC_VERSION: &str = "2.0"; +pub const MCP_PROTOCOL_VERSION: &str = "2025-06-18"; + +pub const PARSE_ERROR: i64 = -32700; +pub const INVALID_REQUEST: i64 = -32600; +pub const METHOD_NOT_FOUND: i64 = -32601; +pub const INVALID_PARAMS: i64 = -32602; +pub const INTERNAL_ERROR: i64 = -32603; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcId { + Number(i64), + String(String), +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl JsonRpcRequest { + pub fn call(id: JsonRpcId, method: impl Into, params: Value) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.into(), + id: Some(id), + method: method.into(), + params: Some(params), + } + } + + pub fn notification(method: impl Into, params: Value) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.into(), + id: None, + method: method.into(), + params: Some(params), + } + } + + pub fn is_notification(&self) -> bool { + self.id.is_none() + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl JsonRpcError { + pub fn new(code: i64, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + pub fn with_data(mut self, data: Value) -> Self { + self.data = Some(data); + self + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl JsonRpcResponse { + pub fn result(id: Option, result: Value) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.into(), + id, + result: Some(result), + error: None, + } + } + + pub fn error(id: Option, error: JsonRpcError) -> Self { + Self { + jsonrpc: JSONRPC_VERSION.into(), + id, + result: None, + error: Some(error), + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcMessage { + Request(JsonRpcRequest), + Response(JsonRpcResponse), +} + +pub fn parse(payload: &str) -> Result { + let value: Value = serde_json::from_str(payload) + .map_err(|error| JsonRpcError::new(PARSE_ERROR, format!("Parse error: {error}")))?; + if value.get("jsonrpc").and_then(Value::as_str) != Some(JSONRPC_VERSION) { + return Err(JsonRpcError::new(INVALID_REQUEST, "Invalid Request")); + } + serde_json::from_value(value) + .map_err(|error| JsonRpcError::new(INVALID_REQUEST, format!("Invalid Request: {error}"))) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn request_and_notification_round_trip() { + let request = JsonRpcMessage::Request(JsonRpcRequest::call( + JsonRpcId::Number(7), + "tools/call", + json!({"name": "agent_click", "arguments": {"nodeId": 42}}), + )); + let encoded = serde_json::to_string(&request).unwrap(); + assert_eq!(parse(&encoded).unwrap(), request); + + let notification = JsonRpcMessage::Request(JsonRpcRequest::notification( + "notifications/agent/treeChanged", + json!({"revision": 9}), + )); + let encoded = serde_json::to_string(¬ification).unwrap(); + assert_eq!(parse(&encoded).unwrap(), notification); + } + + #[test] + fn malformed_or_non_jsonrpc_frames_fail_explicitly() { + assert_eq!(parse("not-json").unwrap_err().code, PARSE_ERROR); + assert_eq!( + parse(r#"{"method":"tools/list"}"#).unwrap_err().code, + INVALID_REQUEST + ); + } +} diff --git a/src/libs/ws/traits.rs b/src/libs/ws/traits.rs index acb7609..a4c9746 100644 --- a/src/libs/ws/traits.rs +++ b/src/libs/ws/traits.rs @@ -1,12 +1,16 @@ use std::fmt; +#[cfg(feature = "ws-core")] use std::net::SocketAddr; use async_trait::async_trait; +#[cfg(feature = "ws-core")] use crossfire::{AsyncRx, mpsc::Array}; +#[cfg(feature = "ws-core")] use eyre::Result; use tokio::io::{AsyncRead, AsyncWrite}; use super::WsMessage as Message; +#[cfg(feature = "ws-core")] use super::WsServerConfig; /// Combined trait alias for a raw byte stream that can be used across thread boundaries. @@ -63,6 +67,7 @@ pub use MessageStream as WsStream; /// An upgrade event yielded by the upgrader. /// Contains the on_upgrade future and the negotiated protocol. +#[cfg(feature = "ws-core")] pub struct UpgradeEvent { /// Only present with a hyper-based backend (`ws` or `ws-client`), which is /// what provides the `hyper` dependency. @@ -71,6 +76,7 @@ pub struct UpgradeEvent { pub protocol: String, } +#[cfg(feature = "ws-core")] #[async_trait] pub trait WsUpgrader: Send + Sync { /// Returns a receiver that yields upgrade events.