diff --git a/Cargo.lock b/Cargo.lock index a71588a..d3816d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1076,6 +1076,8 @@ dependencies = [ "axum", "clap", "rite-server", + "serde", + "serde_json", "tokio", "tracing", "tracing-subscriber", diff --git a/README.md b/README.md index 35cd665..65646b8 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,24 @@ Endpoints: - `GET /sources` — configured source adapters - `POST /event/github` — authenticated GitHub webhook ingress using `X-Hub-Signature-256` +## CLI and MCP + +Rite's agent-facing read operations are generated from `api/operations.yaml` and run through the +same `execute_operation` dispatch as HTTP. They load the local configuration and print JSON, so +no HTTP server or webhook secret is required: + +```bash +cargo run -p rite-cli -- list-sources --config rite.example.toml +cargo run -p rite-cli -- list-handlers --config rite.example.toml +cargo run -p rite-cli -- get-handler github-pr-opened --config rite.example.toml +``` + +`rite serve --config rite.toml --github-webhook-secret "$RITE_GITHUB_WEBHOOK_SECRET"` explicitly +starts the server; omitting the subcommand remains equivalent for compatibility. The generated +`generated/mcp.json` declares the same read operations for MCP hosts over the existing HTTP +endpoints: `GET /sources`, `GET /handlers`, and `GET /handlers/{handler_id}`. No separate server +surface is needed. + When enabled, the Iris source subscribes to `GET /v1/events` in the background. It reconnects with exponential backoff (1–60 seconds), so Rite remains healthy while Iris is unavailable. Iris messages become `source = "iris"` events. `event_type` is the Iris message kind and metadata diff --git a/crates/rite-cli/Cargo.toml b/crates/rite-cli/Cargo.toml index 1f9ae2e..8d41deb 100644 --- a/crates/rite-cli/Cargo.toml +++ b/crates/rite-cli/Cargo.toml @@ -12,6 +12,8 @@ path = "src/main.rs" axum.workspace = true clap.workspace = true rite-server = { path = "../rite-server" } +serde.workspace = true +serde_json.workspace = true tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/crates/rite-cli/src/main.rs b/crates/rite-cli/src/main.rs index 15aa543..df68d6b 100644 --- a/crates/rite-cli/src/main.rs +++ b/crates/rite-cli/src/main.rs @@ -1,35 +1,56 @@ -use std::{net::SocketAddr, path::PathBuf}; +use std::{collections::BTreeMap, net::SocketAddr, path::PathBuf}; -use clap::Parser; +use clap::{Parser, Subcommand}; use rite_server::{ - DiagnosticLevel, app, configured_state, load_config, start_iris_subscription, startup_summary, - validate, + DiagnosticLevel, app, configured_state, dispatch::OperationInput, load_config, + start_iris_subscription, startup_summary, validate, }; +mod generated { + include!("../../../generated/cli.rs"); +} + #[derive(Parser)] #[command(name = "rite", about = "Minimal event-to-action runtime")] struct Cli { - #[arg(long, env = "RITE_CONFIG", default_value = "rite.toml")] + #[arg(long, global = true, env = "RITE_CONFIG", default_value = "rite.toml")] config: PathBuf, - #[arg(long, env = "RITE_GITHUB_WEBHOOK_SECRET")] - github_webhook_secret: String, - #[arg(long, default_value = "127.0.0.1:8080")] + #[arg(long, global = true, env = "RITE_GITHUB_WEBHOOK_SECRET")] + github_webhook_secret: Option, + #[arg(long, global = true, default_value = "127.0.0.1:8080")] listen: SocketAddr, + #[command(subcommand)] + command: Option, } -#[tokio::main] -async fn main() -> Result<(), Box> { - tracing_subscriber::fmt::init(); - let cli = Cli::parse(); - let config = load_config(&std::fs::read_to_string(cli.config)?)?; +#[derive(Subcommand)] +enum Command { + /// Run the HTTP server (the default when no subcommand is supplied). + Serve, + /// Generated read operations from the API contract. + #[command(flatten)] + Generated(generated::GeneratedCommand), +} + +fn print_json_error(message: impl std::fmt::Display) { + println!("{}", serde_json::json!({ "error": message.to_string() })); +} + +fn load_validated_config( + path: &PathBuf, + emit_diagnostics: bool, +) -> Result> { + let config = load_config(&std::fs::read_to_string(path)?)?; let diagnostics = validate(&config); - for diagnostic in &diagnostics { - match diagnostic.level { - DiagnosticLevel::Warning => { - tracing::warn!(message = %diagnostic.message, "Rite configuration warning"); - } - DiagnosticLevel::Error => { - tracing::error!(message = %diagnostic.message, "Rite configuration error"); + if emit_diagnostics { + for diagnostic in &diagnostics { + match diagnostic.level { + DiagnosticLevel::Warning => { + tracing::warn!(message = %diagnostic.message, "Rite configuration warning"); + } + DiagnosticLevel::Error => { + tracing::error!(message = %diagnostic.message, "Rite configuration error"); + } } } } @@ -39,9 +60,80 @@ async fn main() -> Result<(), Box> { { return Err("invalid Rite configuration".into()); } + Ok(config) +} + +fn read_input(command: &generated::GeneratedCommand) -> OperationInput { + let parameters = command.parameters_json(); + let path = parameters + .as_object() + .into_iter() + .flatten() + .map(|(key, value)| (key.clone(), value.as_str().unwrap_or_default().to_owned())) + .collect::>(); + OperationInput { + path, + query: BTreeMap::new(), + body: serde_json::Value::Null, + } +} - let state = configured_state(&cli.github_webhook_secret, config.clone())?; - let listener = tokio::net::TcpListener::bind(cli.listen).await?; +async fn run_read( + config: rite_server::RiteConfig, + command: generated::GeneratedCommand, +) -> Result<(), rite_server::dispatch::OperationError> { + // Read commands never accept webhook traffic, so they do not need a real secret. + // The placeholder only constructs the existing source registry consistently. + let state = configured_state("read-only-cli", config).map_err(|error| { + rite_server::dispatch::OperationError { + status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, + message: error.to_string(), + } + })?; + let result = rite_server::dispatch::execute_operation( + &state, + command.operation_name(), + read_input(&command), + ) + .await?; + println!("{result}"); + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + let Cli { + config: config_path, + github_webhook_secret, + listen, + command, + } = Cli::parse(); + let command = command.unwrap_or(Command::Serve); + if let Command::Generated(command) = command { + let config = match load_validated_config(&config_path, false) { + Ok(config) => config, + Err(error) => { + print_json_error(error); + std::process::exit(1); + } + }; + if let Err(error) = run_read(config, command).await { + print_json_error(error.message); + std::process::exit(1); + } + return Ok(()); + } + + let config = load_validated_config(&config_path, true)?; + if !matches!(command, Command::Serve) { + unreachable!("all command variants are handled"); + } + let secret = github_webhook_secret.ok_or( + "--github-webhook-secret or RITE_GITHUB_WEBHOOK_SECRET is required to serve webhooks", + )?; + let state = configured_state(&secret, config.clone())?; + let listener = tokio::net::TcpListener::bind(listen).await?; tracing::info!("{}", startup_summary(&config)); start_iris_subscription(&state); axum::serve(listener, app(state)).await?;