1use crate::application::Application;
35use crate::cache::CacheLayer;
36use crate::core::json::escape as json_escape;
37use crate::mcp::{extract_arg, McpContent, McpServer, ToolAnnotations};
38use crate::middleware::WithMiddleware;
39use crate::request::Request;
40use crate::response::Response;
41use crate::server::ConnectionInfo;
42
43pub use crate::entry_point::RwsEnv;
44
45#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
46mod db;
47mod generate;
48mod layout;
49#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
50mod models;
51mod ops;
52#[cfg(test)]
53mod tests;
54
55pub use generate::GEN_TOOLS;
56pub use ops::{ops_tools, OPS_WRITE_TOOLS};
57
58pub const DEV_WRITE_TOOLS: [&str; 2] = ["migrations_run", "migrations_rollback_last"];
62
63pub const DEFAULT_TOKEN_ENV: &str = "MCP_TOKEN";
67
68#[derive(Clone)]
83pub struct DevTools {
84 name: String,
85 version: String,
86 path: String,
87 env: Option<RwsEnv>,
88 token: Option<String>,
89 token_env: String,
90 writes: bool,
91 ops: bool,
92 cache: Option<CacheLayer>,
93 record_requests: bool,
94 allowed_origins: Vec<String>,
95 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
96 database: Option<crate::model::DbPool>,
97 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
98 migrations: Option<String>,
99 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
100 models: Vec<crate::model::ModelInfo>,
101 app_dir: Option<std::path::PathBuf>,
102}
103
104impl Default for DevTools {
105 fn default() -> Self {
106 DevTools::new()
107 }
108}
109
110impl DevTools {
111 pub fn new() -> Self {
115 DevTools {
116 name: "rws-dev".to_string(),
117 version: env!("CARGO_PKG_VERSION").to_string(),
118 path: "/mcp".to_string(),
119 env: None,
120 token: None,
121 token_env: DEFAULT_TOKEN_ENV.to_string(),
122 writes: true,
123 ops: false,
124 cache: None,
125 record_requests: true,
126 allowed_origins: Vec::new(),
127 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
128 database: None,
129 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
130 migrations: None,
131 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
132 models: Vec::new(),
133 app_dir: None,
134 }
135 }
136
137 pub fn server_info(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
140 self.name = name.into();
141 self.version = version.into();
142 self
143 }
144
145 pub fn at(mut self, path: impl Into<String>) -> Self {
148 self.path = path.into();
149 self
150 }
151
152 pub fn env(mut self, env: RwsEnv) -> Self {
154 self.env = Some(env);
155 self
156 }
157
158 pub fn token(mut self, token: impl Into<String>) -> Self {
161 self.token = Some(token.into());
162 self
163 }
164
165 pub fn token_env(mut self, name: impl Into<String>) -> Self {
168 self.token_env = name.into();
169 self
170 }
171
172 pub fn read_only(mut self) -> Self {
176 self.writes = false;
177 self
178 }
179
180 pub fn with_ops(mut self) -> Self {
184 self.ops = true;
185 self
186 }
187
188 pub fn cache(mut self, cache: CacheLayer) -> Self {
192 self.cache = Some(cache);
193 self.ops = true;
194 self
195 }
196
197 pub fn record_requests(mut self, record: bool) -> Self {
202 self.record_requests = record;
203 self
204 }
205
206 pub fn allowed_origins<I, S>(mut self, origins: I) -> Self
210 where
211 I: IntoIterator<Item = S>,
212 S: Into<String>,
213 {
214 self.allowed_origins = origins.into_iter().map(Into::into).collect();
215 self
216 }
217
218 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
220 pub fn database(mut self, pool: crate::model::DbPool) -> Self {
221 self.database = Some(pool);
222 self
223 }
224
225 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
229 pub fn migrations(mut self, dir: impl Into<String>) -> Self {
230 self.migrations = Some(dir.into());
231 self
232 }
233
234 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
240 pub fn model<T: crate::model::Model>(mut self) -> Self {
241 let info = crate::model::ModelInfo::of::<T>();
242 if !self.models.contains(&info) {
243 self.models.push(info);
244 }
245 self
246 }
247
248 pub fn app_dir(mut self, dir: impl Into<std::path::PathBuf>) -> Self {
255 self.app_dir = Some(dir.into());
256 self
257 }
258
259 fn effective_token(&self) -> Option<String> {
262 let token = match &self.token {
263 Some(token) => token.clone(),
264 None => std::env::var(&self.token_env).unwrap_or_default(),
265 };
266 let token = token.trim();
267 (!token.is_empty()).then(|| token.to_string())
268 }
269
270 fn effective_env(&self) -> RwsEnv {
271 self.env.unwrap_or_else(RwsEnv::current)
272 }
273
274 pub fn enabled(&self) -> bool {
277 self.effective_env().is_dev() || self.effective_token().is_some()
278 }
279
280 pub fn register(&self, mcp: McpServer) -> McpServer {
291 let routes = mcp.registered_routes().into_iter().map(|r| (r.method, r.pattern)).collect();
292 let middleware = mcp.middleware_names();
293 self.install(mcp, routes, middleware)
294 }
295
296 fn install(&self, mcp: McpServer, routes: Vec<(String, String)>, middleware: Vec<&'static str>) -> McpServer {
297 let mut mcp = routes_tool(mcp, routes.clone(), middleware);
298 let env = self.effective_env();
299 mcp = mcp.tool(
300 "config",
301 "The app's effective configuration: RWS_ENV, compiled-in features and every RWS_*, OTEL_* and MCP_* environment setting (config file and flags included), secrets hidden",
302 r#"{"type":"object"}"#,
303 move |_| Ok(McpContent::json(config_json(env))),
304 );
305 let dir = self.app_dir.clone().or_else(|| std::env::current_dir().ok()).unwrap_or_default();
306 mcp = layout::install(mcp, dir.clone());
307 if self.writes {
308 mcp = generate::install(mcp, dir);
309 }
310 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
311 {
312 mcp = models::install(mcp, self.models.clone(), self.database.clone());
313 }
314 mcp = if self.ops {
315 ops::install(mcp, routes, self.cache.clone(), self.writes)
316 } else {
317 recent_tools(mcp)
318 };
319 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
320 if let Some(pool) = &self.database {
321 mcp = db::install(mcp, pool.clone(), self.migrations.clone(), self.writes);
322 }
323 mcp.tool_annotations("routes", read_only_hint()).tool_annotations("config", read_only_hint())
324 }
325
326 pub fn mount<A: Application + Send + Sync + 'static>(self, app: A) -> DevMcp<A> {
329 let env = self.effective_env();
330 let token = self.effective_token();
331 if !env.is_dev() && token.is_none() {
332 println!(
333 "dev MCP not mounted: RWS_ENV is {env} and {} is not set (set RWS_ENV=dev, or a token, to enable {}).",
334 self.token_env, self.path
335 );
336 return DevMcp::Off(app);
337 }
338 let routes = app.registered_routes().into_iter().map(|r| (r.method, r.pattern)).collect();
339 let middleware = app.middleware_names();
340 let mut mcp = McpServer::new(self.name.clone(), self.version.clone())
341 .at(self.path.clone())
342 .allowed_origins(self.allowed_origins.clone());
343 match &token {
344 Some(token) => mcp = mcp.require_bearer(token.clone()),
345 None => println!("dev MCP at {} without a token (RWS_ENV=dev): keep the port local, or set {}.", self.path, self.token_env),
346 }
347 mcp = if self.record_requests {
348 mcp.wrap(WithMiddleware::new(app).wrap(crate::request_log::LogLayer))
349 } else {
350 mcp.wrap(app)
351 };
352 let mcp = self.install(mcp, routes, middleware);
353 let audited = crate::admin::access::McpAuditLayer::new(OPS_WRITE_TOOLS.iter().chain(DEV_WRITE_TOOLS.iter()).chain(GEN_TOOLS.iter()).copied());
355 DevMcp::On(WithMiddleware::new(mcp).wrap(audited))
356 }
357}
358
359#[derive(Clone)]
363pub enum DevMcp<A> {
364 On(WithMiddleware<McpServer>),
367 Off(A),
369}
370
371impl<A> DevMcp<A> {
372 pub fn is_mounted(&self) -> bool {
374 matches!(self, DevMcp::On(_))
375 }
376}
377
378impl<A: Application> Application for DevMcp<A> {
379 fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
380 match self {
381 DevMcp::On(m) => m.execute(request, connection),
382 DevMcp::Off(a) => a.execute(request, connection),
383 }
384 }
385
386 fn runs_inline(&self, request: &Request, connection: &ConnectionInfo) -> bool {
387 match self {
388 DevMcp::On(m) => m.runs_inline(request, connection),
389 DevMcp::Off(a) => a.runs_inline(request, connection),
390 }
391 }
392
393 #[cfg(feature = "http2")]
394 fn execute_async<'a>(&'a self, request: &'a Request, connection: &'a ConnectionInfo) -> Option<crate::application::AsyncResponse<'a>> {
395 match self {
396 DevMcp::On(m) => m.execute_async(request, connection),
397 DevMcp::Off(a) => a.execute_async(request, connection),
398 }
399 }
400
401 fn streams_body(&self, request: &Request, connection: &ConnectionInfo) -> bool {
402 match self {
403 DevMcp::On(m) => m.streams_body(request, connection),
404 DevMcp::Off(a) => a.streams_body(request, connection),
405 }
406 }
407
408 fn registered_routes(&self) -> Vec<crate::router::RouteInfo> {
409 match self {
410 DevMcp::On(m) => m.registered_routes(),
411 DevMcp::Off(a) => a.registered_routes(),
412 }
413 }
414
415 fn middleware_names(&self) -> Vec<&'static str> {
416 match self {
417 DevMcp::On(m) => m.middleware_names(),
418 DevMcp::Off(a) => a.middleware_names(),
419 }
420 }
421}
422
423pub trait DevMcpExt: Application + Sized + Send + Sync + 'static {
426 fn dev_mcp(self) -> DevMcp<Self> {
429 DevTools::new().mount(self)
430 }
431
432 fn dev_mcp_with(self, tools: DevTools) -> DevMcp<Self> {
434 tools.mount(self)
435 }
436}
437
438impl<A: Application + Send + Sync + 'static> DevMcpExt for A {}
439
440pub(crate) fn read_only_hint() -> ToolAnnotations {
443 ToolAnnotations { read_only_hint: Some(true), open_world_hint: Some(false), ..ToolAnnotations::default() }
444}
445
446fn quoted(s: &str) -> String {
447 format!("\"{}\"", json_escape(s))
448}
449
450fn recent_tools(mcp: McpServer) -> McpServer {
453 mcp.tool(
454 "recent_requests",
455 "Last N requests recorded by the server (default 20, max 100)",
456 r#"{"type":"object","properties":{"n":{"type":"integer","description":"Number of entries to return (default 20)"}}}"#,
457 |args| {
458 let n = extract_arg(args, "n").and_then(|v| v.parse::<usize>().ok()).unwrap_or(20);
459 Ok(McpContent::json(crate::admin::ops::recent_json(n, false)))
460 },
461 )
462 .tool(
463 "recent_errors",
464 "Last N requests with a 4xx or 5xx status code (default 20, max 100)",
465 r#"{"type":"object","properties":{"n":{"type":"integer","description":"Number of error entries to return (default 20)"}}}"#,
466 |args| {
467 let n = extract_arg(args, "n").and_then(|v| v.parse::<usize>().ok()).unwrap_or(20);
468 Ok(McpContent::json(crate::admin::ops::recent_json(n, true)))
469 },
470 )
471 .tool_annotations("recent_requests", read_only_hint())
472 .tool_annotations("recent_errors", read_only_hint())
473}
474
475fn routes_tool(mcp: McpServer, routes: Vec<(String, String)>, middleware: Vec<&'static str>) -> McpServer {
477 let json = routes_json(&routes, &middleware);
478 mcp.tool(
479 "routes",
480 "The app's routes (method and pattern, in registration order) and the middleware layers around them, in the order a request passes them; the layers apply to every route listed",
481 r#"{"type":"object"}"#,
482 move |_| Ok(McpContent::json(json.clone())),
483 )
484}
485
486pub(crate) fn routes_json(routes: &[(String, String)], middleware: &[&str]) -> String {
487 let routes: Vec<String> =
488 routes.iter().map(|(m, p)| format!(r#"{{"method":{},"pattern":{}}}"#, quoted(m), quoted(p))).collect();
489 let middleware: Vec<String> = middleware.iter().map(|m| quoted(m)).collect();
490 format!(r#"{{"routes":[{}],"middleware":[{}]}}"#, routes.join(","), middleware.join(","))
491}
492
493fn shown_setting(name: &str) -> bool {
495 ["RWS_", "OTEL_", "MCP_"].iter().any(|prefix| name.starts_with(prefix)) || name == "DATABASE_URL"
496}
497
498pub fn masked(name: &str, value: &str) -> String {
502 let shown = crate::entry_point::shown(name, value);
503 hide_url_password(&shown)
504}
505
506fn hide_url_password(value: &str) -> String {
507 let Some(scheme_end) = value.find("://") else { return value.to_string() };
508 let authority_start = scheme_end + 3;
509 let rest = &value[authority_start..];
510 let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
511 let Some(at) = authority.rfind('@') else { return value.to_string() };
512 let Some(colon) = authority[..at].find(':') else { return value.to_string() };
513 format!("{}(hidden){}", &value[..authority_start + colon + 1], &value[authority_start + at..])
514}
515
516pub(crate) fn config_json(env: RwsEnv) -> String {
518 let mut pairs: Vec<(String, String)> = std::env::vars().filter(|(k, _)| shown_setting(k)).collect();
519 pairs.sort();
520 let settings: Vec<String> = pairs.iter().map(|(k, v)| format!("{}:{}", quoted(k), quoted(&masked(k, v)))).collect();
521 format!(
522 r#"{{"rws_env":{},"features":{},"settings":{{{}}}}}"#,
523 quoted(env.as_str()),
524 crate::admin::ops::build_features_json(),
525 settings.join(",")
526 )
527}