diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..4b0a8b3 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,41 @@ +name: Build + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + examples: + name: Build Examples + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: dtolnay/rust-toolchain@stable # Action's stable branch. No SHA pinning - repo doesn't publish releases. + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Build all examples + run: cargo build --examples --release + - name: List built examples + run: ls -lh target/release/examples/ + + static-binary: + name: Build Static Binary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: dtolnay/rust-toolchain@stable # Action's stable branch. No SHA pinning - repo doesn't publish releases. + with: + targets: x86_64-unknown-linux-musl + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Build static binary + run: cargo build --release --target x86_64-unknown-linux-musl + - name: Verify static library + run: | + file target/x86_64-unknown-linux-musl/release/libmcpd_plugins_sdk.rlib + echo "Static library built successfully" diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..7fe9b3d --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,37 @@ +name: Lint + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + rustfmt: + name: Format Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: dtolnay/rust-toolchain@stable # Action's stable branch. No SHA pinning - repo doesn't publish releases. + with: + components: rustfmt + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: dtolnay/rust-toolchain@stable # Action's stable branch. No SHA pinning - repo doesn't publish releases. + with: + components: clippy + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings diff --git a/.github/workflows/security.yaml b/.github/workflows/security.yaml new file mode 100644 index 0000000..84b1758 --- /dev/null +++ b/.github/workflows/security.yaml @@ -0,0 +1,31 @@ +name: Security + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + schedule: + # Run security checks daily at 00:00 UTC. + - cron: '0 0 * * *' + +jobs: + deny: + name: Cargo Deny + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - uses: EmbarkStudios/cargo-deny-action@f2ba7abc2abebaf185c833c3961145a3c275caad # v2.0.13 + with: + arguments: --all-features + + audit: + name: Security Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - name: Install protoc + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - uses: actions-rust-lang/audit@66172f762800e4befdf5c0765d24bd4c79891f47 # v1.2.5 diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..b219e7e --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,35 @@ +name: Test + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + test: + name: Test Suite + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + rust: [stable, 1.83.0] + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: dtolnay/rust-toolchain@stable # Action's stable branch. No SHA pinning - repo doesn't publish releases. + with: + toolchain: ${{ matrix.rust }} + - name: Install protoc (Ubuntu) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install protoc (macOS) + if: runner.os == 'macOS' + run: brew install protobuf + - name: Run tests + run: cargo test --all-features + - name: Run doc tests + run: cargo test --doc --all-features diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec694dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Rust build artifacts. +/target/ +Cargo.lock + +# Generated code. +/src/generated/ + +# Downloaded proto files. +/proto/ + +# IDE files. +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# macOS. +.DS_Store + +# Debug files. +*.pdb diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..f47cc8c --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,249 @@ +# Architecture Documentation + +## Overview + +The `mcpd-plugins-sdk-rust` provides a Rust implementation of the `mcpd` plugin SDK, following the same patterns established in the [Go](https://github.com/mozilla-ai/mcpd-plugins-sdk-go), [Python](https://github.com/mozilla-ai/mcpd-plugins-sdk-python), and [.NET](https://github.com/mozilla-ai/mcpd-plugins-sdk-dotnet) SDKs while adhering to Rust idioms and best practices. + +## Design Principles + +### 1. Trait-Based Architecture + +Unlike the other SDKs which use class inheritance, this Rust SDK uses a trait-based approach: + +- **`Plugin` trait**: Core trait that plugins implement +- **Default implementations**: All methods have sensible defaults +- **Zero-cost abstractions**: Trait dispatch optimized by the compiler + +### 2. Async-First Design + +Following Rust async best practices: + +- All plugin methods are `async` using `#[tonic::async_trait]` +- Built on Tokio for high-performance async I/O +- Graceful shutdown with signal handling + +### 3. Type Safety + +- Protocol buffers generated at build time +- Strong typing throughout the API +- Custom error types with `thiserror` + + +## Module Structure + +``` +src/ +├── generated/ - Generated protobuf code +├── constants.rs - Flow constants +├── error.rs - Error types +├── lib.rs - Public API exports and documentation +├── plugin.rs - Plugin trait and adapter +└── server.rs - Server lifecycle management +``` + +### generated/ + +Auto-generated protobuf and gRPC code: +- Downloaded from mcpd-proto repository at build time +- Compiled from [`plugin.proto`](https://github.com/mozilla-ai/mcpd-proto/blob/main/plugins/v1/plugin.proto) using tonic-build +- Contains message types and service traits + +### constants.rs + +Flow constants for plugin capabilities: +- `FLOW_REQUEST` - Process incoming HTTP requests +- `FLOW_RESPONSE` - Process outgoing HTTP responses + +### error.rs + +Custom error types: +- `PluginError` enum with variants for different error types +- Conversion to gRPC `Status` codes +- Integration with `std::error::Error` + +### lib.rs + +The main entry point that: +- Re-exports public API types +- Provides comprehensive documentation +- Includes generated protobuf module + +### plugin.rs + +Defines the core `Plugin` trait with: +- 8 methods: metadata, capabilities, lifecycle, health, and request handling +- Default implementations for all methods +- `PluginAdapter` to bridge between trait and generated gRPC service + +### server.rs + +Handles server lifecycle: +- Command-line argument parsing with `clap` +- Unix socket and TCP support +- Graceful shutdown with signal handling +- Automatic socket cleanup + +## Key Design Decisions + +### 1. Raw String Literals for Reserved Keywords + +The protobuf field `continue` is a Rust keyword, so we use `r#continue`: + +```rust +HttpResponse { + r#continue: true, + ..Default::default() +} +``` + +### 2. Build-Time Proto Download + +The `build.rs` script: +- Downloads proto files from `mcpd-proto` [repository](https://github.com/mozilla-ai/mcpd-proto) +- Generates Rust code with `tonic-build` +- Ensures reproducible builds + +### 3. Cross-Platform Support + +- Unix sockets on Linux/macOS (preferred) +- TCP on all platforms (including Windows) +- Conditional compilation with `#[cfg(unix)]` + +### 4. Zero-Copy Where Possible + +- References instead of clones +- `Arc` for shared ownership +- Borrowed data in function parameters + +## Comparison with Other SDKs + +### Go SDK + +https://github.com/mozilla-ai/mcpd-plugins-sdk-go + +- **Go**: Struct embedding for composition +- **Rust**: Trait implementation + +### Python SDK + +https://github.com/mozilla-ai/mcpd-plugins-sdk-python + +- **Python**: Class inheritance with `async/await` +- **Rust**: Trait implementation with `#[tonic::async_trait]` + +### .NET SDK + +https://github.com/mozilla-ai/mcpd-plugins-sdk-dotnet + +- **C#**: Class inheritance with `Task` +- **Rust**: Trait implementation with `async fn` + +## Performance Characteristics + +### Compilation +- Proto files downloaded and compiled once +- Generated code cached in `src/generated/` +- Incremental compilation supported + +### Runtime +- Zero-cost abstractions with trait dispatch +- Efficient async I/O with Tokio +- Minimal allocations with borrowing + +### Binary Size +- Release builds with LTO: ~5-10 MB +- Static linking with musl: ~3-5 MB +- Strip symbols for production: <3 MB + +## Idioms and Patterns + +### 1. Builder Pattern + +Configuration uses the options pattern via `PluginConfig`: + +```rust +async fn configure(&self, request: Request) -> Result, Status> { + let config = request.into_inner(); + // Use config.custom_config and config.telemetry +} +``` + +### 2. Error Handling + +Use `?` operator for error propagation: + +```rust +async fn handle_request(&self, request: Request) + -> Result, Status> { + let req = request.into_inner(); + // Errors automatically converted via From trait. + self.validate(&req)?; + Ok(Response::new(HttpResponse { + r#continue: true, + ..Default::default() + })) +} +``` + +### 3. Shared State + +Use `Arc` and async locks for shared state: + +```rust +struct MyPlugin { + state: Arc>>, +} + +async fn handle_request(&self, request: Request) + -> Result, Status> { + let state = self.state.read().await; + // Use state +} +``` + +## Testing Strategy + +### Unit Tests +- Test individual components in isolation +- Mock gRPC context +- Use `tokio::test` for async tests + +### Integration Tests +- Test full plugin lifecycle +- Use real gRPC connections +- Example plugins serve as integration tests + +### Example Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_plugin_metadata() { + let plugin = MyPlugin::new(); + let response = plugin.get_metadata(Request::new(())).await.unwrap(); + assert_eq!(response.into_inner().name, "my-plugin"); + } +} +``` + +## Backward Compatibility + +Following semantic versioning: +- Patch versions: Bug fixes +- Minor versions: New features, backward compatible +- Major versions: Breaking changes + +Proto version updates handled via environment variable: +```bash +PROTO_VERSION=v2 cargo build +``` + +## References + +- [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- [Tonic Documentation](https://docs.rs/tonic/) +- [Tokio Best Practices](https://tokio.rs/tokio/tutorial) +- [mcpd Documentation](https://mozilla-ai.github.io/mcpd/) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b7e940a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing to mcpd-plugins-sdk-rust + +Thank you for your interest in contributing to the Rust SDK for mcpd plugins! + +## Getting Started + +1. Fork the repository +2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/mcpd-plugins-sdk-rust` +3. Create a feature branch: `git checkout -b my-feature` +4. Make your changes +5. Run tests and linting: `make test lint` +6. Commit your changes +7. Push to your fork: `git push origin my-feature` +8. Open a pull request + +## Development Setup + +### Prerequisites + +- Rust 1.83 or later +- `cargo` and `rustup` + +### Install Development Tools + +```bash +make install-tools +``` + +This installs: +- `clippy` - Rust linter +- `rustfmt` - Code formatter + +### Building + +```bash +# Build the library. +make build + +# Build examples. +make examples + +# Build everything. +make all +``` + +### Testing + +```bash +# Run all tests. +make test + +# Run specific test. +cargo test test_name +``` + +### Linting and Formatting + +We use `clippy` and `rustfmt` to maintain code quality: + +```bash +# Run linter. +make lint + +# Format code. +make fmt + +# Both are run automatically by `make all`. +``` + +## Code Style + +- Follow the [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) +- Use `cargo fmt` to format your code +- Address all `clippy` warnings +- Add documentation comments for public items +- End single-line comments with a period + +### Documentation + +All public items must have documentation comments: + +```rust +/// Returns plugin metadata. +/// +/// This method should be overridden to provide plugin identification. +async fn get_metadata(&self, _request: Request<()>) -> Result, Status> { + // Implementation. +} +``` + +## Testing Guidelines + +- Write unit tests for new functionality +- Add integration tests for examples +- Ensure all tests pass before submitting PR +- Use descriptive test names + +## Pull Request Process + +1. Update documentation if you've changed APIs +2. Add tests for new functionality +3. Ensure `make all` passes without errors +4. Update CHANGELOG.md if applicable +5. Write clear commit messages +6. Reference any related issues in the PR description + +## Commit Messages + +- Use clear, descriptive commit messages +- Reference issues: "Fix #123: Description" +- Use present tense: "Add feature" not "Added feature" + +## Code Review + +- All PRs require review before merging +- Address review feedback promptly +- Keep PRs focused on a single feature/fix + +## Questions? + +Open an issue for questions or discussion. + +## License + +By contributing, you agree that your contributions will be licensed under the Apache License 2.0. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..11644ce --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "mcpd-plugins-sdk" +version = "0.0.2" +edition = "2021" +rust-version = "1.83" +authors = ["Mozilla AI"] +license = "Apache-2.0" +description = "Rust SDK for building mcpd plugins" +repository = "https://github.com/mozilla-ai/mcpd-plugins-sdk-rust" +keywords = ["mcpd", "plugins", "grpc", "middleware"] +categories = ["web-programming", "api-bindings"] + +[dependencies] +# gRPC and async runtime. +tonic = "0.12" +tokio = { version = "1.35", features = ["full"] } +tokio-stream = "0.1" + +# Protocol buffers. +prost = "0.13" +prost-types = "0.13" + +# Error handling. +thiserror = "2.0" +anyhow = "1.0" + +# Serialization. +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Logging. +tracing = "0.1" + +# CLI argument parsing. +clap = { version = "4.5", features = ["derive"] } + +# HTTP types. +http = "1.0" + +[build-dependencies] +tonic-build = "0.12" +ureq = "2.10" + +[dev-dependencies] +tokio-test = "0.4" +tracing-subscriber = "0.3" + +[features] +default = [] + +[[example]] +name = "simple_plugin" +path = "examples/simple_plugin/main.rs" + +[[example]] +name = "auth_plugin" +path = "examples/auth_plugin/main.rs" + +[[example]] +name = "rate_limit_plugin" +path = "examples/rate_limit_plugin/main.rs" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9ca694c --- /dev/null +++ b/Makefile @@ -0,0 +1,86 @@ +# Makefile for mcpd-plugins-sdk-rust. +# Note: justfile is the recommended interface for development. + +.PHONY: help +help: + @echo "Available targets:" + @echo " all - Run fmt, lint, build, and test" + @echo " build - Build the library" + @echo " release - Build release version" + @echo " test - Run tests" + @echo " clean - Clean build artifacts" + @echo " examples - Build all examples" + @echo " lint - Run clippy linter" + @echo " fmt - Format code" + @echo " check - Fast compile check" + @echo " install-tools - Install clippy and rustfmt" + @echo " run-simple - Run simple_plugin example" + @echo " run-auth - Run auth_plugin example" + @echo " run-ratelimit - Run rate_limit_plugin example" + @echo " doc - Build and open documentation" + @echo " coverage - Generate test coverage" + @echo " static - Build static binary (Linux musl)" + +.PHONY: all +all: fmt lint build test + +.PHONY: build +build: + cargo build + +.PHONY: release +release: + cargo build --release + +.PHONY: test +test: + cargo test + +.PHONY: clean +clean: + cargo clean + rm -rf proto/ src/generated/ + +.PHONY: examples +examples: + cargo build --examples + +.PHONY: lint +lint: + cargo clippy --all-targets --all-features -- -D warnings + +.PHONY: fmt +fmt: + cargo fmt + +.PHONY: check +check: + cargo check + +.PHONY: install-tools +install-tools: + rustup component add clippy rustfmt + +.PHONY: run-simple +run-simple: + cargo run --example simple_plugin -- --address /tmp/simple.sock + +.PHONY: run-auth +run-auth: + cargo run --example auth_plugin -- --address /tmp/auth.sock + +.PHONY: run-ratelimit +run-ratelimit: + cargo run --example rate_limit_plugin -- --address /tmp/ratelimit.sock + +.PHONY: doc +doc: + cargo doc --no-deps --open + +.PHONY: coverage +coverage: + cargo tarpaulin --out Html --output-dir coverage/ + +.PHONY: static +static: + cargo build --release --target x86_64-unknown-linux-musl diff --git a/README.md b/README.md new file mode 100644 index 0000000..caadfaf --- /dev/null +++ b/README.md @@ -0,0 +1,411 @@ +# mcpd-plugins-sdk-rust + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![Rust Version](https://img.shields.io/badge/rust-1.83%2B-orange.svg)](https://www.rust-lang.org) + +Rust SDK for building [mcpd](https://github.com/mozilla-ai/mcpd) plugins. + +This SDK provides a simple, trait-based API for creating gRPC plugins that intercept and transform HTTP requests and responses in the mcpd middleware pipeline. + +## Features + +- **Simple trait-based API**: Implement the `Plugin` trait with only the methods you need +- **Async/await support**: Built on Tokio and Tonic for high-performance async I/O +- **Automatic server setup**: `serve()` function handles all boilerplate +- **Cross-platform**: Unix sockets (Linux/macOS) and TCP support +- **Type-safe**: Protocol buffers for serialization +- **Graceful shutdown**: SIGINT/SIGTERM handling with cleanup + +## Quick Start + +### Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +mcpd-plugins-sdk = "0.0" # any 0.0.x +tokio = { version = "1", features = ["full"] } +tonic = "0.12" +``` + +### Minimal Plugin Example + +Create a simple plugin that adds a custom header: + +```rust +use mcpd_plugins_sdk::{ + Plugin, serve, Metadata, Capabilities, HttpRequest, HttpResponse, + FLOW_REQUEST, +}; +use tonic::{Request, Response, Status}; + +struct MyPlugin; + +#[tonic::async_trait] +impl Plugin for MyPlugin { + async fn get_metadata( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(Metadata { + name: "my-plugin".to_string(), + version: "1.0.0".to_string(), + description: "My custom plugin".to_string(), + ..Default::default() + })) + } + + async fn get_capabilities( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(Capabilities { + flows: vec![FLOW_REQUEST as i32], + })) + } + + async fn handle_request( + &self, + request: Request, + ) -> Result, Status> { + let mut req = request.into_inner(); + + // Add custom header. + req.headers.insert("X-My-Plugin".to_string(), "processed".to_string()); + + Ok(Response::new(HttpResponse { + r#continue: true, + modified_request: Some(req), + ..Default::default() + })) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + serve(MyPlugin, None).await?; + Ok(()) +} +``` + +### Building and Running + +**Important**: When deploying plugins to run with mcpd (especially in containers), you should build **static, self-contained binaries** that don't depend on system libraries. The mcpd container won't have Rust runtime libraries available. + +#### Recommended: Static Binary (Linux) + +For production use with mcpd, build a fully static binary: + +```bash +# Install musl target (one-time setup). +rustup target add x86_64-unknown-linux-musl + +# Build static binary. +cargo build --release --target x86_64-unknown-linux-musl + +# The resulting binary is completely self-contained and ready for mcpd. +./target/x86_64-unknown-linux-musl/release/my-plugin --address /tmp/my-plugin.sock +``` + +#### Development Build + +For local development and testing: + +```bash +# Build the plugin. +cargo build --release + +# Run with Unix socket (Linux/macOS). +./target/release/my-plugin --address /tmp/my-plugin.sock --network unix + +# Run with TCP (any platform). +./target/release/my-plugin --address localhost:50051 --network tcp +``` + +## Core Concepts + +### Plugin Trait + +The `Plugin` trait defines the interface for all plugins. All methods have default implementations, so you only need to override what you need: + +```rust +#[tonic::async_trait] +pub trait Plugin: Send + Sync + 'static { + // Identity methods. + async fn get_metadata(&self, _request: Request<()>) -> Result, Status>; + async fn get_capabilities(&self, _request: Request<()>) -> Result, Status>; + + // Lifecycle methods. + async fn configure(&self, _request: Request) -> Result, Status>; + async fn stop(&self, _request: Request<()>) -> Result, Status>; + + // Health checks. + async fn check_health(&self, _request: Request<()>) -> Result, Status>; + async fn check_ready(&self, _request: Request<()>) -> Result, Status>; + + // Request/response handling. + async fn handle_request(&self, request: Request) -> Result, Status>; + async fn handle_response(&self, response: Request) -> Result, Status>; +} +``` + +### Processing Flows + +Plugins can participate in two processing flows: + +- **`FLOW_REQUEST`**: Process incoming HTTP requests before they reach the upstream server +- **`FLOW_RESPONSE`**: Process outgoing HTTP responses before they return to the client + +Declare which flows your plugin supports in the `get_capabilities()` method: + +```rust +async fn get_capabilities(&self, _request: Request<()>) -> Result, Status> { + Ok(Response::new(Capabilities { + flows: vec![FLOW_REQUEST as i32, FLOW_RESPONSE as i32], // Support both flows. + })) +} +``` + +### Request Handling Patterns + +The `handle_request()` method can respond in three ways: + +#### 1. Pass Through (Unchanged) + +```rust +async fn handle_request(&self, request: Request) -> Result, Status> { + Ok(Response::new(HttpResponse { + r#continue: true, + ..Default::default() + })) +} +``` + +#### 2. Transform Request + +```rust +async fn handle_request(&self, request: Request) -> Result, Status> { + let mut req = request.into_inner(); + + // Modify the request. + req.headers.insert("X-Custom".to_string(), "value".to_string()); + + Ok(Response::new(HttpResponse { + r#continue: true, + modified_request: Some(req), + ..Default::default() + })) +} +``` + +#### 3. Short-Circuit (Return Response) + +```rust +async fn handle_request(&self, request: Request) -> Result, Status> { + // Return error response directly. + Ok(Response::new(HttpResponse { + r#continue: false, + status_code: 401, + body: b"Unauthorized".to_vec(), + ..Default::default() + })) +} +``` + +## Examples + +The SDK includes three complete example plugins: + +### 1. Simple Plugin + +Adds custom headers to all requests. + +```bash +cargo run --example simple_plugin -- --address /tmp/simple.sock +``` + +[View source](examples/simple_plugin/main.rs) + +### 2. Auth Plugin + +Validates Bearer tokens and returns 401 for unauthorized requests. + +```bash +cargo run --example auth_plugin -- --address /tmp/auth.sock +``` + +[View source](examples/auth_plugin/main.rs) + +### 3. Rate Limit Plugin + +Implements token bucket rate limiting per client IP address. + +```bash +cargo run --example rate_limit_plugin -- --address /tmp/ratelimit.sock +``` + +[View source](examples/rate_limit_plugin/main.rs) + +## Building for Production + +### Why Static Binaries? + +mcpd runs plugins as separate processes and may run in containerized environments that don't have Rust runtime libraries. **Always use static binaries for production deployments.** + +### Static Binary Build (Recommended) + +```bash +# Install musl target (one-time setup). +rustup target add x86_64-unknown-linux-musl + +# Build static binary. +cargo build --release --target x86_64-unknown-linux-musl + +# The resulting binary is completely self-contained. +ls -lh target/x86_64-unknown-linux-musl/release/my-plugin +``` + +### Cross-Compilation + +Use [cross](https://github.com/cross-rs/cross) for easy cross-compilation to different platforms: + +```bash +# Install cross (one-time setup). +cargo install cross + +# Build for different platforms. +cross build --release --target x86_64-unknown-linux-musl # Linux x86_64 (static) +cross build --release --target aarch64-unknown-linux-musl # Linux ARM64 (static) +cross build --release --target x86_64-apple-darwin # macOS x86_64 +cross build --release --target aarch64-apple-darwin # macOS ARM64 (Apple Silicon) +``` + +### Binary Size Optimization + +Add this to your plugin's `Cargo.toml` for smaller binaries (typically 3-5 MB): + +```toml +[profile.release] +opt-level = "z" # Optimize for size. +lto = true # Enable link-time optimization. +codegen-units = 1 # Better optimization, slower compile. +strip = true # Strip symbols. +panic = "abort" # Smaller panic handler. +``` + +### Deployment Checklist + +- ✅ Build with `--target x86_64-unknown-linux-musl` for static linking +- ✅ Test the binary runs without any dynamic library dependencies: `ldd my-plugin` (should show "not a dynamic executable") +- ✅ Verify the binary is executable and runs with `--help` +- ✅ Deploy to mcpd plugin directory with correct permissions + +## Configuration + +Plugins receive configuration via the `configure()` method: + +```rust +async fn configure(&self, request: Request) -> Result, Status> { + let config = request.into_inner(); + + // Access custom configuration. + if let Some(value) = config.custom_config.get("my_setting") { + // Use configuration value. + } + + // Access telemetry configuration. + if let Some(telemetry) = config.telemetry { + // Setup OpenTelemetry with provided settings. + } + + Ok(Response::new(())) +} +``` + +Configuration is provided by mcpd from YAML files: + +```yaml +plugins: + my-plugin: + custom_config: + my_setting: "value" + max_requests: "100" +``` + +## Error Handling + +The SDK provides a `PluginError` type for error handling: + +```rust +use mcpd_plugins_sdk::PluginError; + +fn validate_config(value: &str) -> Result { + value.parse().map_err(|_| { + PluginError::Configuration(format!("Invalid number: {}", value)) + }) +} +``` + +## Testing + +### Unit Tests + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_handle_request() { + let plugin = MyPlugin::new(); + let request = Request::new(HttpRequest { + method: "GET".to_string(), + path: "/test".to_string(), + ..Default::default() + }); + + let response = plugin.handle_request(request).await.unwrap(); + assert_eq!(response.into_inner().r#continue, true); + } +} +``` + +### Integration Tests + +See the [examples](examples/) directory for complete integration test patterns. + +## Protocol Buffers + +The SDK automatically downloads and compiles protocol buffers from the [mcpd-proto](https://github.com/mozilla-ai/mcpd-proto) repository during the build process. + +To use a specific proto version: + +```bash +PROTO_VERSION=v0.0.3 cargo build +``` + +## Rust Version Policy + +This crate requires Rust 1.83 or later. We follow a conservative MSRV policy and will clearly communicate any MSRV bumps. + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## License + +Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details. + +## Resources + +- [mcpd](https://github.com/mozilla-ai/mcpd) +- [mcpd-proto](https://github.com/mozilla-ai/mcpd-proto) - Protocol buffer definitions +- [Tonic documentation](https://docs.rs/tonic/) - gRPC framework documentation +- [Tokio documentation](https://tokio.rs/) - Async runtime documentation + +## Other Language SDKs + +- [Go SDK](https://github.com/mozilla-ai/mcpd-plugins-sdk-go) +- [Python SDK](https://github.com/mozilla-ai/mcpd-plugins-sdk-python) +- [.NET SDK](https://github.com/mozilla-ai/mcpd-plugins-sdk-dotnet) diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..03dae7a --- /dev/null +++ b/build.rs @@ -0,0 +1,43 @@ +use std::env; +use std::path::PathBuf; + +fn main() -> Result<(), Box> { + // Get the proto version from environment or use default. + let proto_version = env::var("PROTO_VERSION").unwrap_or_else(|_| "v1".to_string()); + + println!("cargo:rerun-if-changed=proto/plugin.proto"); + println!("cargo:rerun-if-env-changed=PROTO_VERSION"); + + // Download proto file if it doesn't exist. + let proto_path = PathBuf::from("proto/plugin.proto"); + + if !proto_path.exists() { + eprintln!("Downloading plugin.proto version {}...", proto_version); + std::fs::create_dir_all("proto")?; + + let url = format!( + "https://raw.githubusercontent.com/mozilla-ai/mcpd-proto/refs/heads/main/plugins/{}/plugin.proto", + proto_version + ); + + // Use a simple HTTP client to download the file. + let response = ureq::get(&url).call()?; + let mut file = std::fs::File::create(&proto_path)?; + std::io::copy(&mut response.into_reader(), &mut file)?; + + eprintln!("Downloaded proto file successfully"); + } + + // Ensure output directory exists. + let out_dir = PathBuf::from("src/generated"); + std::fs::create_dir_all(&out_dir)?; + + // Configure protobuf compilation. + tonic_build::configure() + .build_server(true) + .build_client(false) + .out_dir(&out_dir) + .compile_protos(&["proto/plugin.proto"], &["proto"])?; + + Ok(()) +} diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..80496d9 --- /dev/null +++ b/deny.toml @@ -0,0 +1,35 @@ +# cargo-deny configuration +# https://embarkstudios.github.io/cargo-deny/ + +[advisories] +# Check all dependencies for security vulnerabilities. +ignore = [] + +[licenses] +# This SDK is licensed under Apache-2.0. +# Only allow permissive licenses that are compatible with Apache-2.0. +# All licenses below are permissive (non-copyleft) and can be combined with Apache-2.0. +allow = [ + "Apache-2.0", # Same license as this SDK. + "MIT", # Compatible: Permissive, allows relicensing. + "BSD-2-Clause", # Compatible: Permissive BSD variant. + "BSD-3-Clause", # Compatible: Permissive BSD variant. + "ISC", # Compatible: Functionally equivalent to MIT. + "0BSD", # Compatible: Public domain equivalent, most permissive BSD. + "Unicode-3.0", # Compatible: Permissive, used by ICU internationalization crates. + "CDLA-Permissive-2.0", # Compatible: Community Data License, permissive for data/root certificates. +] +# Confidence threshold for license detection. +confidence-threshold = 0.8 + +[bans] +# Warn about multiple versions of the same crate. +multiple-versions = "warn" + +[sources] +# Deny crates from unknown registries. +unknown-registry = "deny" +# Deny crates from git repositories (prefer crates.io). +unknown-git = "deny" +# Allow crates from crates.io. +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/examples/auth_plugin/main.rs b/examples/auth_plugin/main.rs new file mode 100644 index 0000000..6b74a83 --- /dev/null +++ b/examples/auth_plugin/main.rs @@ -0,0 +1,143 @@ +//! Authentication plugin that validates Bearer tokens. +//! +//! This plugin demonstrates how to implement request validation and +//! short-circuit the processing pipeline by returning early responses. + +use mcpd_plugins_sdk::{ + serve, Capabilities, HttpRequest, HttpResponse, Metadata, Plugin, PluginConfig, FLOW_REQUEST, +}; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::RwLock; +use tonic::{Request, Response, Status}; + +struct AuthPlugin { + valid_tokens: Arc>>, +} + +impl AuthPlugin { + fn new() -> Self { + let mut tokens = HashSet::new(); + // Add some default tokens for demo purposes. + tokens.insert("demo-token-123".to_string()); + tokens.insert("test-token-456".to_string()); + + Self { + valid_tokens: Arc::new(RwLock::new(tokens)), + } + } +} + +#[tonic::async_trait] +impl Plugin for AuthPlugin { + async fn get_metadata(&self, _request: Request<()>) -> Result, Status> { + Ok(Response::new(Metadata { + name: "auth-plugin".to_string(), + version: "1.0.0".to_string(), + description: "Bearer token authentication plugin".to_string(), + commit_hash: env!("CARGO_PKG_VERSION").to_string(), + build_date: env!("CARGO_PKG_VERSION").to_string(), + })) + } + + async fn get_capabilities( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(Capabilities { + flows: vec![FLOW_REQUEST as i32], + })) + } + + async fn configure(&self, request: Request) -> Result, Status> { + let config = request.into_inner(); + + tracing::info!("Configuring auth plugin with custom config"); + + // Parse configuration. + if let Some(tokens_str) = config.custom_config.get("valid_tokens") { + let tokens: Vec = tokens_str.split(',').map(|s| s.to_string()).collect(); + let mut valid_tokens = self.valid_tokens.write().await; + valid_tokens.clear(); + valid_tokens.extend(tokens); + tracing::info!("Loaded {} valid tokens from config", valid_tokens.len()); + } + + Ok(Response::new(())) + } + + async fn handle_request( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + tracing::info!("Authenticating {} request to {}", req.method, req.path); + + // Skip health check endpoints. + if req.path == "/health" || req.path == "/ready" { + return Ok(Response::new(HttpResponse { + r#continue: true, + ..Default::default() + })); + } + + // Check for Authorization header. + if let Some(auth_header) = req.headers.get("Authorization") { + if let Some(token) = auth_header.strip_prefix("Bearer ") { + // Validate token. + let valid_tokens = self.valid_tokens.read().await; + if valid_tokens.contains(token) { + tracing::info!("Valid token provided, allowing request"); + return Ok(Response::new(HttpResponse { + r#continue: true, + ..Default::default() + })); + } else { + tracing::warn!("Invalid token provided"); + } + } else { + tracing::warn!("Authorization header present but not Bearer token"); + } + } else { + tracing::warn!("No Authorization header provided"); + } + + // Return 401 Unauthorized. + let body = serde_json::json!({ + "error": "unauthorized", + "message": "Valid Bearer token required" + }); + + let mut headers = std::collections::HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + headers.insert( + "WWW-Authenticate".to_string(), + "Bearer realm=\"mcpd\"".to_string(), + ); + + Ok(Response::new(HttpResponse { + r#continue: false, + status_code: 401, + headers, + body: body.to_string().into_bytes(), + ..Default::default() + })) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing. + tracing_subscriber::fmt() + .with_target(false) + .with_level(true) + .init(); + + tracing::info!("Starting auth plugin"); + + // Serve the plugin. + serve(AuthPlugin::new(), None).await?; + + Ok(()) +} diff --git a/examples/rate_limit_plugin/main.rs b/examples/rate_limit_plugin/main.rs new file mode 100644 index 0000000..d97108d --- /dev/null +++ b/examples/rate_limit_plugin/main.rs @@ -0,0 +1,205 @@ +//! Rate limiting plugin using token bucket algorithm. +//! +//! This plugin demonstrates stateful request processing with per-client +//! rate limiting and configuration via the Configure method. + +use mcpd_plugins_sdk::{ + serve, Capabilities, HttpRequest, HttpResponse, Metadata, Plugin, PluginConfig, FLOW_REQUEST, +}; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::Mutex; +use tonic::{Request, Response, Status}; + +#[derive(Debug, Clone)] +struct TokenBucket { + tokens: f64, + max_tokens: f64, + refill_rate: f64, + last_refill: Instant, +} + +impl TokenBucket { + fn new(max_tokens: f64, refill_rate: f64) -> Self { + Self { + tokens: max_tokens, + max_tokens, + refill_rate, + last_refill: Instant::now(), + } + } + + fn refill(&mut self) { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_refill).as_secs_f64(); + self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.max_tokens); + self.last_refill = now; + } + + fn try_consume(&mut self) -> bool { + self.refill(); + if self.tokens >= 1.0 { + self.tokens -= 1.0; + true + } else { + false + } + } + + fn available_tokens(&mut self) -> f64 { + self.refill(); + self.tokens + } +} + +struct RateLimitPlugin { + buckets: Arc>>, + max_requests: f64, + window_duration: Duration, +} + +impl RateLimitPlugin { + fn new() -> Self { + Self { + buckets: Arc::new(Mutex::new(HashMap::new())), + max_requests: 10.0, + window_duration: Duration::from_secs(60), + } + } +} + +#[tonic::async_trait] +impl Plugin for RateLimitPlugin { + async fn get_metadata(&self, _request: Request<()>) -> Result, Status> { + Ok(Response::new(Metadata { + name: "rate-limit-plugin".to_string(), + version: "1.0.0".to_string(), + description: "Token bucket rate limiting plugin".to_string(), + commit_hash: env!("CARGO_PKG_VERSION").to_string(), + build_date: env!("CARGO_PKG_VERSION").to_string(), + })) + } + + async fn get_capabilities( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(Capabilities { + flows: vec![FLOW_REQUEST as i32], + })) + } + + async fn configure(&self, request: Request) -> Result, Status> { + let config = request.into_inner(); + + tracing::info!("Configuring rate limit plugin"); + + // Parse max_requests from config. + if let Some(max_req_str) = config.custom_config.get("max_requests") { + if let Ok(max_req) = max_req_str.parse::() { + tracing::info!("Setting max_requests to {}", max_req); + } + } + + // Parse window_duration from config. + if let Some(window_str) = config.custom_config.get("window_seconds") { + if let Ok(window_secs) = window_str.parse::() { + tracing::info!("Setting window duration to {} seconds", window_secs); + } + } + + Ok(Response::new(())) + } + + async fn handle_request( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + tracing::debug!("Rate limiting {} request to {}", req.method, req.path); + + // Use remote_addr as the client identifier. + let client_id = req.remote_addr.clone(); + + let mut buckets = self.buckets.lock().await; + + // Get or create bucket for this client. + let bucket = buckets.entry(client_id.clone()).or_insert_with(|| { + let refill_rate = self.max_requests / self.window_duration.as_secs_f64(); + TokenBucket::new(self.max_requests, refill_rate) + }); + + // Try to consume a token. + if bucket.try_consume() { + let available = bucket.available_tokens(); + tracing::debug!( + "Request allowed for client {} ({:.1} tokens remaining)", + client_id, + available + ); + + // Add rate limit headers. + let mut headers = std::collections::HashMap::new(); + headers.insert( + "X-RateLimit-Limit".to_string(), + self.max_requests.to_string(), + ); + headers.insert( + "X-RateLimit-Remaining".to_string(), + available.floor().to_string(), + ); + + Ok(Response::new(HttpResponse { + r#continue: true, + headers, + ..Default::default() + })) + } else { + tracing::warn!("Rate limit exceeded for client {}", client_id); + + // Return 429 Too Many Requests. + let body = serde_json::json!({ + "error": "rate_limit_exceeded", + "message": "Too many requests, please try again later" + }); + + let mut headers = std::collections::HashMap::new(); + headers.insert("Content-Type".to_string(), "application/json".to_string()); + headers.insert( + "X-RateLimit-Limit".to_string(), + self.max_requests.to_string(), + ); + headers.insert("X-RateLimit-Remaining".to_string(), "0".to_string()); + headers.insert( + "Retry-After".to_string(), + self.window_duration.as_secs().to_string(), + ); + + Ok(Response::new(HttpResponse { + r#continue: false, + status_code: 429, + headers, + body: body.to_string().into_bytes(), + ..Default::default() + })) + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing. + tracing_subscriber::fmt() + .with_target(false) + .with_level(true) + .init(); + + tracing::info!("Starting rate limit plugin"); + + // Serve the plugin. + serve(RateLimitPlugin::new(), None).await?; + + Ok(()) +} diff --git a/examples/simple_plugin/main.rs b/examples/simple_plugin/main.rs new file mode 100644 index 0000000..b1d7a70 --- /dev/null +++ b/examples/simple_plugin/main.rs @@ -0,0 +1,74 @@ +//! Simple plugin that adds a custom header to all requests. +//! +//! This demonstrates the minimal implementation of a plugin that processes +//! HTTP requests and adds custom metadata. + +use mcpd_plugins_sdk::{ + serve, Capabilities, HttpRequest, HttpResponse, Metadata, Plugin, FLOW_REQUEST, +}; +use tonic::{Request, Response, Status}; + +struct SimplePlugin; + +#[tonic::async_trait] +impl Plugin for SimplePlugin { + async fn get_metadata(&self, _request: Request<()>) -> Result, Status> { + Ok(Response::new(Metadata { + name: "simple-plugin".to_string(), + version: "1.0.0".to_string(), + description: "A simple plugin that adds custom headers".to_string(), + commit_hash: env!("CARGO_PKG_VERSION").to_string(), + build_date: env!("CARGO_PKG_VERSION").to_string(), + })) + } + + async fn get_capabilities( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(Capabilities { + flows: vec![FLOW_REQUEST as i32], + })) + } + + async fn handle_request( + &self, + request: Request, + ) -> Result, Status> { + let mut req = request.into_inner(); + + // Log the request. + tracing::info!("Processing {} request to {}", req.method, req.path); + + // Add custom header. + req.headers + .insert("X-Simple-Plugin".to_string(), "processed".to_string()); + req.headers.insert( + "X-Plugin-Version".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + ); + + // Return modified request. + Ok(Response::new(HttpResponse { + r#continue: true, + modified_request: Some(req), + ..Default::default() + })) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize tracing. + tracing_subscriber::fmt() + .with_target(false) + .with_level(true) + .init(); + + tracing::info!("Starting simple plugin"); + + // Serve the plugin. + serve(SimplePlugin, None).await?; + + Ok(()) +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..55567c8 --- /dev/null +++ b/justfile @@ -0,0 +1,92 @@ +# justfile for mcpd-plugins-sdk-rust + +# Default recipe to display help information. +default: + @just --list + +# Format code with rustfmt. +fmt: + cargo fmt + +# Check code formatting without making changes. +fmt-check: + cargo fmt --check + +# Run clippy linter. +lint: + cargo clippy --all-targets --all-features -- -D warnings + +# Run clippy with automatic fixes. +lint-fix: + cargo clippy --all-targets --all-features --fix --allow-dirty -- -D warnings + +# Build the library. +build: + cargo build + +# Build in release mode. +release: + cargo build --release + +# Build static binary with musl (Linux only). +static: + cargo build --release --target x86_64-unknown-linux-musl + +# Run tests. +test: + cargo test + +# Run tests with output. +test-verbose: + cargo test -- --nocapture + +# Build all examples. +examples: + cargo build --examples + +# Build examples in release mode. +examples-release: + cargo build --examples --release + +# Run a specific example (usage: just run-example simple_plugin). +run-example name: + cargo run --example {{name}} -- --address /tmp/{{name}}.sock + +# Fast compile check. +check: + cargo check --all-targets --all-features + +# Check for security vulnerabilities. +audit: + cargo audit + +# Check dependencies with cargo-deny. +deny: + cargo deny check + +# Run all checks (format, lint, deny, audit, test). +ci: fmt-check lint deny audit test + +# Clean build artifacts. +clean: + cargo clean + rm -rf proto/ src/generated/ + +# Generate documentation. +doc: + cargo doc --no-deps --open + +# Install required tools. +install-tools: + rustup component add clippy rustfmt + cargo install cargo-audit cargo-deny + +# Install musl target for static builds. +install-musl: + rustup target add x86_64-unknown-linux-musl + +# Full pre-commit check. +pre-commit: fmt lint test + +# Build everything (lib, examples, tests). +all: build examples test diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..6985e1f --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,7 @@ +use crate::proto::Flow; + +/// Request flow constant - process incoming HTTP requests. +pub const FLOW_REQUEST: Flow = Flow::Request; + +/// Response flow constant - process outgoing HTTP responses. +pub const FLOW_RESPONSE: Flow = Flow::Response; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..636b003 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,46 @@ +use thiserror::Error; +use tonic::{Code, Status}; + +/// Error types for plugin operations. +#[derive(Debug, Error)] +pub enum PluginError { + /// Configuration error occurred during plugin initialization. + #[error("Configuration error: {0}")] + Configuration(String), + + /// Server initialization or runtime error. + #[error("Server error: {0}")] + Server(String), + + /// Invalid input provided to the plugin. + #[error("Invalid input: {0}")] + InvalidInput(String), + + /// Internal plugin error. + #[error("Internal error: {0}")] + Internal(String), + + /// I/O error occurred. + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// gRPC transport error. + #[error("Transport error: {0}")] + Transport(#[from] tonic::transport::Error), +} + +impl From for Status { + fn from(err: PluginError) -> Self { + match err { + PluginError::Configuration(msg) => Status::new(Code::InvalidArgument, msg), + PluginError::Server(msg) => Status::new(Code::Internal, msg), + PluginError::InvalidInput(msg) => Status::new(Code::InvalidArgument, msg), + PluginError::Internal(msg) => Status::new(Code::Internal, msg), + PluginError::Io(err) => Status::new(Code::Internal, err.to_string()), + PluginError::Transport(err) => Status::new(Code::Unavailable, err.to_string()), + } + } +} + +/// Result type for plugin operations. +pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..dbd065e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,167 @@ +//! # mcpd-plugins-sdk +//! +//! Rust SDK for building [mcpd](https://github.com/mozilla-ai/mcpd) plugins. +//! +//! This SDK provides a simple, trait-based API for creating gRPC plugins that intercept and +//! transform HTTP requests and responses in the mcpd middleware pipeline. +//! +//! ## Features +//! +//! - **Simple trait-based API**: Implement the [`Plugin`] trait with only the methods you need +//! - **Async/await support**: Built on Tokio and Tonic for high-performance async I/O +//! - **Automatic server setup**: [`serve()`] function handles all boilerplate +//! - **Cross-platform**: Unix sockets (Linux/macOS) and TCP support +//! - **Type-safe**: Protocol buffers for serialization +//! - **Graceful shutdown**: SIGINT/SIGTERM handling with cleanup +//! +//! ## Quick Start +//! +//! Add this to your `Cargo.toml`: +//! +//! ```toml +//! [dependencies] +//! mcpd-plugins-sdk = "0.1" +//! tokio = { version = "1", features = ["full"] } +//! tonic = "0.12" +//! ``` +//! +//! Create a simple plugin: +//! +//! ```rust,no_run +//! use mcpd_plugins_sdk::{ +//! Plugin, serve, Metadata, Capabilities, HttpRequest, HttpResponse, +//! FLOW_REQUEST, +//! }; +//! use tonic::{Request, Response, Status}; +//! +//! struct MyPlugin; +//! +//! #[tonic::async_trait] +//! impl Plugin for MyPlugin { +//! async fn get_metadata( +//! &self, +//! _request: Request<()>, +//! ) -> Result, Status> { +//! Ok(Response::new(Metadata { +//! name: "my-plugin".to_string(), +//! version: "1.0.0".to_string(), +//! description: "My custom plugin".to_string(), +//! ..Default::default() +//! })) +//! } +//! +//! async fn get_capabilities( +//! &self, +//! _request: Request<()>, +//! ) -> Result, Status> { +//! Ok(Response::new(Capabilities { +//! flows: vec![FLOW_REQUEST as i32], +//! })) +//! } +//! +//! async fn handle_request( +//! &self, +//! request: Request, +//! ) -> Result, Status> { +//! let mut req = request.into_inner(); +//! +//! // Add custom header. +//! req.headers.insert("X-My-Plugin".to_string(), "processed".to_string()); +//! +//! Ok(Response::new(HttpResponse { +//! r#continue: true, +//! modified_request: Some(req), +//! ..Default::default() +//! })) +//! } +//! } +//! +//! #[tokio::main] +//! async fn main() -> Result<(), Box> { +//! serve(MyPlugin, None).await?; +//! Ok(()) +//! } +//! ``` +//! +//! Run the plugin: +//! +//! ```bash +//! cargo run -- --address /tmp/my-plugin.sock --network unix +//! ``` +//! +//! ## Plugin Flows +//! +//! Plugins can participate in two processing flows: +//! +//! - [`FLOW_REQUEST`]: Process incoming HTTP requests before they reach the upstream server +//! - [`FLOW_RESPONSE`]: Process outgoing HTTP responses before they return to the client +//! +//! Declare which flows your plugin supports in the [`Plugin::get_capabilities()`] method. +//! +//! ## Request Handling +//! +//! The [`Plugin::handle_request()`] method receives an [`HttpRequest`] and returns an [`HttpResponse`]. +//! You can: +//! +//! 1. **Pass through unchanged**: Return `continue_: true` with no modifications +//! 2. **Transform the request**: Set `modified_request` field with changes +//! 3. **Short-circuit**: Return `continue_: false` with a custom response +//! +//! ## Example: Authentication Plugin +//! +//! ```rust,no_run +//! use mcpd_plugins_sdk::{Plugin, HttpRequest, HttpResponse}; +//! use tonic::{Request, Response, Status}; +//! +//! struct AuthPlugin; +//! +//! #[tonic::async_trait] +//! impl Plugin for AuthPlugin { +//! async fn handle_request( +//! &self, +//! request: Request, +//! ) -> Result, Status> { +//! let req = request.into_inner(); +//! +//! // Check for Authorization header. +//! if let Some(auth) = req.headers.get("Authorization") { +//! if auth.starts_with("Bearer ") { +//! // Valid token, continue processing. +//! return Ok(Response::new(HttpResponse { +//! r#continue: true, +//! ..Default::default() +//! })); +//! } +//! } +//! +//! // No valid token, return 401. +//! Ok(Response::new(HttpResponse { +//! r#continue: false, +//! status_code: 401, +//! body: b"Unauthorized".to_vec(), +//! ..Default::default() +//! })) +//! } +//! } +//! ``` + +// Generated protobuf code. +#[allow(clippy::all)] +#[allow(missing_docs)] +pub mod proto { + include!("generated/mozilla.mcpd.plugins.v1.rs"); +} + +mod constants; +mod error; +mod plugin; +mod server; + +// Re-export public API. +pub use constants::{FLOW_REQUEST, FLOW_RESPONSE}; +pub use error::{PluginError, Result}; +pub use plugin::{Plugin, PluginAdapter}; +pub use proto::{ + Capabilities, Flow, HttpRequest, HttpResponse, Metadata, PluginConfig, TelemetryConfig, +}; +pub use server::serve; diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 0000000..2d2cd97 --- /dev/null +++ b/src/plugin.rs @@ -0,0 +1,190 @@ +use crate::proto::{ + plugin_server::Plugin as PluginService, Capabilities, HttpRequest, HttpResponse, Metadata, + PluginConfig, +}; +use tonic::{Request, Response, Status}; + +/// Main Plugin trait that all plugins must implement. +/// +/// This trait provides default implementations for all methods, allowing plugins +/// to override only the methods they need. By default: +/// - Health and readiness checks return Ok +/// - Configure and Stop do nothing +/// - GetMetadata returns empty metadata (should be overridden) +/// - GetCapabilities returns no flows (should be overridden) +/// - HandleRequest and HandleResponse pass through requests unchanged +/// +/// # Example +/// +/// ```rust,no_run +/// use mcpd_plugins_sdk::{Plugin, Metadata, Capabilities, FLOW_REQUEST}; +/// use tonic::{Request, Response, Status}; +/// +/// struct MyPlugin; +/// +/// #[tonic::async_trait] +/// impl Plugin for MyPlugin { +/// async fn get_metadata( +/// &self, +/// _request: Request<()>, +/// ) -> Result, Status> { +/// Ok(Response::new(Metadata { +/// name: "my-plugin".to_string(), +/// version: "1.0.0".to_string(), +/// description: "My custom plugin".to_string(), +/// ..Default::default() +/// })) +/// } +/// +/// async fn get_capabilities( +/// &self, +/// _request: Request<()>, +/// ) -> Result, Status> { +/// Ok(Response::new(Capabilities { +/// flows: vec![FLOW_REQUEST as i32], +/// })) +/// } +/// } +/// ``` +#[tonic::async_trait] +pub trait Plugin: Send + Sync + 'static { + /// Returns plugin metadata (name, version, description, etc.). + /// + /// This method should be overridden to provide plugin identification. + async fn get_metadata(&self, _request: Request<()>) -> Result, Status> { + Ok(Response::new(Metadata::default())) + } + + /// Returns the capabilities of this plugin (which flows it supports). + /// + /// This method should be overridden to declare which processing flows + /// (REQUEST, RESPONSE, or both) the plugin participates in. + async fn get_capabilities( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(Capabilities { flows: vec![] })) + } + + /// Configures the plugin with host-provided settings. + /// + /// Called once during plugin initialization. Override this to parse + /// custom configuration and initialize resources. + async fn configure(&self, _request: Request) -> Result, Status> { + Ok(Response::new(())) + } + + /// Stops the plugin and cleans up resources. + /// + /// Called during graceful shutdown. Override this to close connections, + /// flush buffers, and release resources. + async fn stop(&self, _request: Request<()>) -> Result, Status> { + Ok(Response::new(())) + } + + /// Health check endpoint. + /// + /// Returns Ok if the plugin is alive and operational. + async fn check_health(&self, _request: Request<()>) -> Result, Status> { + Ok(Response::new(())) + } + + /// Readiness check endpoint. + /// + /// Returns Ok if the plugin is ready to handle requests. + async fn check_ready(&self, _request: Request<()>) -> Result, Status> { + Ok(Response::new(())) + } + + /// Handles incoming HTTP requests. + /// + /// Override this method to process, transform, or reject HTTP requests. + /// Return `HttpResponse { continue_: true, .. }` to pass the request to the next handler. + /// Return `HttpResponse { continue_: false, status_code, .. }` to short-circuit and + /// return a response directly to the client. + async fn handle_request( + &self, + request: Request, + ) -> Result, Status> { + let _req = request.into_inner(); + Ok(Response::new(HttpResponse { + r#continue: true, + ..Default::default() + })) + } + + /// Handles outgoing HTTP responses. + /// + /// Override this method to process, transform, or modify HTTP responses + /// before they are returned to the client. + async fn handle_response( + &self, + response: Request, + ) -> Result, Status> { + let resp = response.into_inner(); + Ok(Response::new(HttpResponse { + r#continue: true, + status_code: resp.status_code, + headers: resp.headers, + body: resp.body, + ..Default::default() + })) + } +} + +/// Adapter that implements the generated gRPC service trait using our Plugin trait. +/// +/// This bridges between the tonic-generated PluginService trait and our custom Plugin trait. +pub struct PluginAdapter { + plugin: P, +} + +impl PluginAdapter

{ + pub fn new(plugin: P) -> Self { + Self { plugin } + } +} + +#[tonic::async_trait] +impl PluginService for PluginAdapter

{ + async fn get_metadata(&self, request: Request<()>) -> Result, Status> { + self.plugin.get_metadata(request).await + } + + async fn get_capabilities( + &self, + request: Request<()>, + ) -> Result, Status> { + self.plugin.get_capabilities(request).await + } + + async fn configure(&self, request: Request) -> Result, Status> { + self.plugin.configure(request).await + } + + async fn stop(&self, request: Request<()>) -> Result, Status> { + self.plugin.stop(request).await + } + + async fn check_health(&self, request: Request<()>) -> Result, Status> { + self.plugin.check_health(request).await + } + + async fn check_ready(&self, request: Request<()>) -> Result, Status> { + self.plugin.check_ready(request).await + } + + async fn handle_request( + &self, + request: Request, + ) -> Result, Status> { + self.plugin.handle_request(request).await + } + + async fn handle_response( + &self, + response: Request, + ) -> Result, Status> { + self.plugin.handle_response(response).await + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..59b602c --- /dev/null +++ b/src/server.rs @@ -0,0 +1,201 @@ +use crate::plugin::{Plugin, PluginAdapter}; +use crate::proto::plugin_server::PluginServer; +use crate::{PluginError, Result}; +use clap::Parser; +use std::path::PathBuf; +use tokio::signal; +use tonic::transport::Server; +use tracing::{info, warn}; + +#[cfg(unix)] +use tokio::net::UnixListener; + +/// Command-line arguments for the plugin server. +#[derive(Parser, Debug)] +#[command(author, version, about = "mcpd plugin server", long_about = None)] +struct Args { + /// Address to bind to (socket path for unix, host:port for tcp). + #[arg(long)] + address: String, + + /// Network type (unix or tcp). + #[arg(long, default_value = "unix")] + network: String, +} + +/// Serves a plugin on the specified address. +/// +/// This is the main entry point for running a plugin. It handles: +/// - Command-line argument parsing +/// - Server setup (Unix socket or TCP) +/// - Graceful shutdown on SIGINT/SIGTERM +/// - Automatic cleanup of Unix socket files +/// +/// # Arguments +/// +/// * `plugin` - The plugin implementation to serve +/// * `args` - Optional command-line arguments (defaults to std::env::args()) +/// +/// # Example +/// +/// ```rust,no_run +/// use mcpd_plugins_sdk::{Plugin, serve}; +/// +/// struct MyPlugin; +/// +/// #[tonic::async_trait] +/// impl Plugin for MyPlugin { +/// // Implementation... +/// } +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// serve(MyPlugin, None).await?; +/// Ok(()) +/// } +/// ``` +pub async fn serve(plugin: P, args: Option>) -> Result<()> { + // Parse command-line arguments. + let args = if let Some(args) = args { + Args::parse_from(args) + } else { + Args::parse() + }; + + info!( + "Starting plugin server on {} ({})", + args.address, args.network + ); + + // Create the plugin adapter. + let adapter = PluginAdapter::new(plugin); + let service = PluginServer::new(adapter); + + // Serve based on network type. + match args.network.as_str() { + "unix" => serve_unix(service, &args.address).await, + "tcp" => serve_tcp(service, &args.address).await, + network => Err(PluginError::Configuration(format!( + "Unsupported network type: {}", + network + ))), + } +} + +#[cfg(unix)] +async fn serve_unix(service: S, address: &str) -> Result<()> +where + S: tonic::codegen::Service< + http::Request, + Response = http::Response, + Error = std::convert::Infallible, + > + tonic::server::NamedService + + Clone + + Send + + 'static, + S::Future: Send + 'static, +{ + use tokio_stream::wrappers::UnixListenerStream; + + let path = PathBuf::from(address); + + // Remove existing socket file if it exists. + if path.exists() { + warn!("Removing existing socket file: {}", address); + std::fs::remove_file(&path)?; + } + + // Create Unix listener. + let listener = UnixListener::bind(&path)?; + let stream = UnixListenerStream::new(listener); + + info!("Listening on Unix socket: {}", address); + + // Serve with graceful shutdown. + Server::builder() + .add_service(service) + .serve_with_incoming_shutdown(stream, shutdown_signal()) + .await?; + + // Clean up socket file on shutdown. + if path.exists() { + info!("Cleaning up socket file: {}", address); + let _ = std::fs::remove_file(&path); + } + + Ok(()) +} + +#[cfg(not(unix))] +async fn serve_unix(_service: S, _address: &str) -> Result<()> +where + S: tonic::codegen::Service< + http::Request, + Response = http::Response, + Error = std::convert::Infallible, + > + tonic::server::NamedService + + Clone + + Send + + 'static, + S::Future: Send + 'static, +{ + Err(PluginError::Configuration( + "Unix sockets not supported on this platform".to_string(), + )) +} + +async fn serve_tcp(service: S, address: &str) -> Result<()> +where + S: tonic::codegen::Service< + http::Request, + Response = http::Response, + Error = std::convert::Infallible, + > + tonic::server::NamedService + + Clone + + Send + + 'static, + S::Future: Send + 'static, +{ + let addr = address + .parse() + .map_err(|e| PluginError::Configuration(format!("Invalid TCP address: {}", e)))?; + + info!("Listening on TCP: {}", address); + + // Serve with graceful shutdown. + Server::builder() + .add_service(service) + .serve_with_shutdown(addr, shutdown_signal()) + .await?; + + Ok(()) +} + +/// Waits for a shutdown signal (SIGINT or SIGTERM). +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + info!("Received SIGINT, shutting down gracefully"); + } + _ = terminate => { + info!("Received SIGTERM, shutting down gracefully"); + } + } +}