Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/rite-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
136 changes: 114 additions & 22 deletions crates/rite-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[arg(long, global = true, default_value = "127.0.0.1:8080")]
listen: SocketAddr,
#[command(subcommand)]
command: Option<Command>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<rite_server::RiteConfig, Box<dyn std::error::Error>> {
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");
}
}
}
}
Expand All @@ -39,9 +60,80 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
{
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::<BTreeMap<_, _>>();
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<dyn std::error::Error>> {
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?;
Expand Down
Loading