tonin_sdk/lib.rs
1//! tonin-sdk: Service builder, Config, Context, Error, and runtime for tonin microservices.
2//!
3//! # Usage
4//!
5//! ```toml
6//! [dependencies]
7//! tonin-sdk = "0.4"
8//! ```
9//!
10//! ```no_run
11//! use tonin_sdk::prelude::*;
12//!
13//! #[tokio::main]
14//! async fn main() -> tonin_sdk::Result<()> {
15//! Service::new("greeter").run().await
16//! }
17//! ```
18//!
19//! # Example
20//!
21//! ```no_run
22//! use tonin_sdk::{Service, Result};
23//! use tonin_sdk::auth::default::JwtValidator;
24//!
25//! #[tokio::main]
26//! async fn main() -> Result<()> {
27//! let verifier = JwtValidator::from_env()?;
28//! Service::new("greeter")
29//! .with_auth(verifier)
30//! .enable_mcp()
31//! .handler(my_grpc_service())
32//! .run()
33//! .await
34//! }
35//!
36//! # use tonic::body::Body;
37//! # #[derive(Clone)]
38//! # struct MyGrpc;
39//! # impl tonic::server::NamedService for MyGrpc {
40//! # const NAME: &'static str = "my.Grpc";
41//! # }
42//! # impl tower::Service<http::Request<Body>> for MyGrpc {
43//! # type Response = http::Response<Body>;
44//! # type Error = std::convert::Infallible;
45//! # type Future = std::pin::Pin<Box<dyn std::future::Future<
46//! # Output = std::result::Result<Self::Response, Self::Error>> + Send>>;
47//! # fn poll_ready(&mut self, _: &mut std::task::Context<'_>)
48//! # -> std::task::Poll<std::result::Result<(), Self::Error>> { unimplemented!() }
49//! # fn call(&mut self, _: http::Request<Body>) -> Self::Future { unimplemented!() }
50//! # }
51//! # fn my_grpc_service() -> MyGrpc { MyGrpc }
52//! ```
53//!
54//! # Modules
55//!
56//! - [`auth`] — token extraction, verification, [`auth::AuthCtx`]
57//! - [`mcp`] — in-process MCP sidecar (second port, shared lifecycle)
58//! - [`telemetry`] — zero-config OTLP tracing + W3C TraceContext
59//! - [`transport`] — tonic/gRPC wiring helpers
60//! - [`discovery`] — k8s DNS-based service resolution
61//! - [`traits`] — capability traits (see below)
62//! - [`state`] — pre-wired DB + cache connections from env at boot
63//! - [`instrumented`] — OTel-span decorators around capability impls
64//! - [`job`] — background job / queue consumer surface
65//! - [`error`] — [`Error`] + [`Result`]
66//!
67//! # Capability traits
68//!
69//! [`traits::Cache`], [`traits::Database`], [`traits::EventBus`], and
70//! [`traits::SecretStore`] are the interface-first surface every service
71//! codes against. Concrete impls live in their own crates and are
72//! selected at deploy time via `tonin.toml` `engine = "..."` — swapping
73//! a backend is a TOML + `Cargo.toml` change, never a handler rewrite.
74//!
75//! # Sample app
76//!
77//! The canonical end-to-end example is
78//! [`examples/greeter`](https://github.com/Rushit/tonin/tree/main/examples/greeter):
79//! one proto, one `main.rs`, one `tonin.toml`.
80//!
81//! # Sibling crates
82//!
83//! - [`tonin`](https://docs.rs/tonin) — umbrella re-export + prelude
84//! - [`tonin-client`](https://docs.rs/tonin-client) — peer-service client primitives
85//! - [`tonin-mcp-macros`](https://docs.rs/tonin-mcp-macros) — `#[mcp_expose]` proc-macro
86//! - [`tonin-build`](https://docs.rs/tonin-build) — `build.rs` helper around tonic-build
87
88use std::net::SocketAddr;
89
90use tower::layer::util::{Identity, Stack};
91
92pub mod auth;
93pub mod discovery;
94pub mod error;
95pub mod instrumented;
96pub mod job;
97pub mod mcp;
98pub mod state;
99pub mod telemetry;
100pub mod traits;
101pub mod transport;
102
103pub use state::State;
104
105pub use error::{Error, Result};
106
107/// The tonic Router type after we install the trace-extract + auth
108/// layers. Both run on every request: telemetry extracts trace context
109/// first, then auth verifies the token.
110type LayeredRouter = tonic::transport::server::Router<
111 Stack<auth::AuthLayer, Stack<crate::telemetry::propagate::ExtractLayer, Identity>>,
112>;
113
114/// Type-erased factory that boots an MCP listener with a custom handler.
115///
116/// Service authors get one of these when they call
117/// [`Service::enable_mcp_with`]; the framework calls the boxed closure
118/// during `run()`. The handler type itself (e.g. `GreeterImplMcpAdapter`
119/// emitted by `#[mcp_expose]`) is erased here so `Service` can stay
120/// generic-free at the type level.
121pub(crate) type McpSpawner = Box<
122 dyn FnOnce(
123 crate::mcp::McpConfig,
124 ) -> std::pin::Pin<
125 Box<
126 dyn std::future::Future<
127 Output = std::result::Result<
128 (SocketAddr, tokio::task::JoinHandle<()>),
129 std::io::Error,
130 >,
131 > + Send,
132 >,
133 > + Send,
134>;
135
136/// Service builder. Holds name, bind address, the tonic router being
137/// assembled, the optional auth layer, and the optional in-process MCP
138/// listener config + custom spawner.
139pub struct Service {
140 name: String,
141 addr: SocketAddr,
142 router: Option<LayeredRouter>,
143 auth_layer: Option<auth::AuthLayer>,
144 mcp: Option<crate::mcp::McpConfig>,
145 /// If set, called to spawn the MCP listener with a custom handler
146 /// (e.g. `GreeterImplMcpAdapter` from `#[mcp_expose]`). If None,
147 /// the default `McpServer` is used.
148 mcp_spawner: Option<McpSpawner>,
149}
150
151impl Service {
152 /// Construct a new service. Initializes OTLP tracing as a side effect
153 /// (idempotent; disabled if `TONIN_TELEMETRY=off`).
154 pub fn new(name: impl Into<String>) -> Self {
155 let name = name.into();
156 // Zero-config telemetry. Errors are logged but do not block startup —
157 // a service should still come up even if the collector is unreachable.
158 if let Err(e) = crate::telemetry::init(&name) {
159 eprintln!("micro: telemetry init failed: {e}");
160 }
161 Self {
162 name,
163 addr: "0.0.0.0:50051".parse().unwrap(),
164 router: None,
165 auth_layer: None,
166 mcp: None,
167 mcp_spawner: None,
168 }
169 }
170
171 pub fn addr(mut self, addr: SocketAddr) -> Self {
172 self.addr = addr;
173 self
174 }
175
176 /// Enable the in-process MCP listener on the default port
177 /// (`0.0.0.0:50052`). Same process, second port — MCP tool calls
178 /// see the same `AuthCtx` task-local, share DB/cache/storage
179 /// connections, and live or die with the gRPC server. To override
180 /// the port use [`Service::mcp_addr`].
181 ///
182 /// What this currently does: spawns a hyper listener that answers
183 /// `GET /health` with 200. The real MCP wire protocol (rmcp +
184 /// `#[tool]` macros bridging each gRPC method) lands in a
185 /// follow-up. This call only commits the framework to the
186 /// "in-process, second port" lifecycle.
187 pub fn enable_mcp(mut self) -> Self {
188 self.mcp = Some(crate::mcp::McpConfig::default());
189 self
190 }
191
192 /// Enable the in-process MCP listener on a specific address.
193 /// Useful for tests (bind `:0` to grab a random free port) or
194 /// for non-default deployments.
195 pub fn mcp_addr(mut self, addr: SocketAddr) -> Self {
196 self.mcp = Some(crate::mcp::McpConfig { addr });
197 self
198 }
199
200 /// Enable the in-process MCP listener with a **custom handler**.
201 /// Use this to wire a `#[tonin::mcp_expose]`-generated adapter
202 /// into the framework so MCP `tools/list` exposes one tool per
203 /// gRPC method on the user's impl.
204 ///
205 /// `factory` is invoked once per MCP session by rmcp's
206 /// `StreamableHttpService` — returning a fresh handler instance
207 /// each time (cheap if the handler is `Arc`-backed, which the
208 /// macro-generated adapter is).
209 ///
210 /// Example:
211 /// ```ignore
212 /// let svc = Service::new("greeter")
213 /// .with_auth(auth::verifier())
214 /// .enable_mcp_with(move || {
215 /// Ok(GreeterImplMcpAdapter::new(GreeterImpl::new(state.clone())))
216 /// });
217 /// ```
218 pub fn enable_mcp_with<H, F>(mut self, factory: F) -> Self
219 where
220 H: crate::mcp::McpServerHandler + Clone + Send + Sync + 'static,
221 F: Fn() -> std::result::Result<H, std::io::Error> + Send + Sync + Clone + 'static,
222 {
223 if self.mcp.is_none() {
224 self.mcp = Some(crate::mcp::McpConfig::default());
225 }
226 let spawner: McpSpawner = Box::new(move |cfg| {
227 Box::pin(async move { crate::mcp::spawn_with(cfg, factory).await })
228 });
229 self.mcp_spawner = Some(spawner);
230 self
231 }
232
233 /// Install an auth verifier with the default
234 /// [`auth::default::BearerHeaderExtractor`].
235 ///
236 /// Every inbound request runs the extractor → verifier chain. The
237 /// resulting [`auth::AuthCtx`] is placed in request extensions AND
238 /// in the [`auth::CURRENT_AUTH`] task-local for the handler's
239 /// execution.
240 ///
241 /// For custom token extraction (cookies, custom headers, etc.) build
242 /// an [`auth::AuthLayer`] directly and pass via
243 /// [`Service::with_auth_layer`].
244 pub fn with_auth<V: auth::TokenVerifier>(mut self, verifier: V) -> Self {
245 let extractor = auth::default::BearerHeaderExtractor;
246 self.auth_layer = Some(auth::AuthLayer::new(extractor, verifier));
247 self
248 }
249
250 /// Install a fully-customized auth layer. Use this when you need a
251 /// non-default token extractor (e.g. cookie-based auth).
252 pub fn with_auth_layer(mut self, layer: auth::AuthLayer) -> Self {
253 self.auth_layer = Some(layer);
254 self
255 }
256
257 /// Run all requests as anonymous. Handlers can still read
258 /// `AuthCtx::from(&req)` — they'll get
259 /// `AuthCtx { kind: PrincipalKind::Anonymous, .. }`.
260 ///
261 /// For services that genuinely don't authenticate (read-only public
262 /// APIs, internal-mesh-only with mTLS as the only check).
263 pub fn without_auth(mut self) -> Self {
264 let extractor = auth::default::BearerHeaderExtractor;
265 let verifier = auth::AnonymousVerifier;
266 self.auth_layer = Some(auth::AuthLayer::new(extractor, verifier).optional());
267 self
268 }
269
270 /// Attach a tonic-generated service. Codegen will call this for the user.
271 ///
272 /// Server-side trace-context extraction is installed once, the first
273 /// time a handler is added; every subsequent handler shares the layer.
274 ///
275 /// The auth layer (if configured via [`Service::with_auth`]) wraps
276 /// each individual service via tonic's per-service layering at
277 /// `run()` time, so it applies uniformly to every handler.
278 pub fn handler<S>(mut self, svc: S) -> Self
279 where
280 S: tower::Service<
281 http::Request<tonic::body::Body>,
282 Response = http::Response<tonic::body::Body>,
283 Error = std::convert::Infallible,
284 > + tonic::server::NamedService
285 + Clone
286 + Send
287 + Sync
288 + 'static,
289 S::Future: Send + 'static,
290 {
291 let auth_layer = self
292 .auth_layer
293 .clone()
294 .unwrap_or_else(default_anonymous_auth);
295 let router: LayeredRouter = match self.router.take() {
296 Some(r) => r.add_service(svc),
297 None => tonic::transport::Server::builder()
298 .http2_keepalive_interval(Some(std::time::Duration::from_secs(60)))
299 .http2_keepalive_timeout(Some(std::time::Duration::from_secs(20)))
300 .layer(crate::telemetry::propagate::extract_layer())
301 .layer(auth_layer)
302 .add_service(svc),
303 };
304 self.router = Some(router);
305 self
306 }
307
308 pub async fn run(self) -> Result<()> {
309 let Service {
310 name,
311 addr,
312 router,
313 auth_layer: _,
314 mcp,
315 mcp_spawner,
316 } = self;
317 let router = router.ok_or_else(|| Error::Config("no handler registered".into()))?;
318
319 // Serve grpc.health.v1.Health so Kubernetes `grpc:` probes work out of
320 // the box. Mark the overall ("") service SERVING; the auth layer
321 // allowlists this path so probes pass without credentials.
322 let (health_reporter, health_service) = tonic_health::server::health_reporter();
323 health_reporter
324 .set_service_status("", tonic_health::ServingStatus::Serving)
325 .await;
326 let router = router.add_service(health_service);
327
328 tracing::info!(service = %name, %addr, "tonin service starting");
329
330 // If MCP is enabled, spawn its listener in parallel. The
331 // gRPC server is still the "main" path — if MCP fails to
332 // bind we log loudly but don't abort, because a service that
333 // serves gRPC without MCP is still useful. (Auth-critical
334 // misconfig would have failed earlier at boot.)
335 let _mcp_handle = if let Some(cfg) = mcp {
336 // Use the custom spawner (from `enable_mcp_with`) if one
337 // was supplied; otherwise default to the bare McpServer
338 // with just the `health` tool.
339 let spawn_result = if let Some(spawner) = mcp_spawner {
340 spawner(cfg).await
341 } else {
342 crate::mcp::spawn(cfg).await
343 };
344 match spawn_result {
345 Ok((bound, handle)) => {
346 tracing::info!(service = %name, mcp_addr = %bound, "mcp listener running");
347 Some(handle)
348 }
349 Err(e) => {
350 tracing::error!(service = %name, error = %e, "mcp listener failed to bind — continuing without MCP");
351 None
352 }
353 }
354 } else {
355 None
356 };
357
358 let serve_result = router.serve(addr).await;
359 // Flush spans before the process exits.
360 crate::telemetry::shutdown();
361 serve_result?;
362 Ok(())
363 }
364}
365
366/// Default auth layer used when [`Service::with_auth`] / `without_auth`
367/// were not called. Treats every request as anonymous; handlers still
368/// see an `AuthCtx { kind: Anonymous }` via `AuthCtx::from(&req)`.
369fn default_anonymous_auth() -> auth::AuthLayer {
370 auth::AuthLayer::new(
371 auth::default::BearerHeaderExtractor,
372 auth::AnonymousVerifier,
373 )
374 .optional()
375}
376
377pub mod prelude {
378 // NOTE: `Result` is intentionally NOT in this prelude. It would
379 // shadow `std::result::Result` in user code, which breaks
380 // macro-generated code (rmcp's tool macros, custom derives) that
381 // emits unqualified `Result<T, E>` two-arg forms. Spell it as
382 // `tonin_sdk::Result<T>` when you want the framework's alias.
383 pub use super::{Error, Service, State};
384 pub use crate::auth::{AuthCtx, AuthError, PrincipalKind};
385 pub use crate::traits::{
386 Cache, Database, EventBus, MessageId, SecretStore, StartPosition, SubscribeOptions,
387 };
388 pub use tonic::{Request, Response, Status};
389}
390
391/// Convenience re-export: `#[tonin_sdk::main]` as an alias for `#[tokio::main]`.
392pub use tokio::main;