1use crate::application::Application;
33use crate::cache::CacheLayer;
34use crate::core::json::escape as json_escape;
35use crate::mcp::{extract_arg, McpContent, McpServer, ToolAnnotations};
36use crate::middleware::WithMiddleware;
37use crate::request::Request;
38use crate::response::Response;
39use crate::server::ConnectionInfo;
40
41pub use crate::entry_point::RwsEnv;
42
43#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
44mod db;
45mod layout;
46#[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
47mod models;
48mod ops;
49#[cfg(test)]
50mod tests;
51
52pub use ops::{ops_tools, OPS_WRITE_TOOLS};
53
54pub const DEV_WRITE_TOOLS: [&str; 2] = ["migrations_run", "migrations_rollback_last"];
58
59pub const DEFAULT_TOKEN_ENV: &str = "MCP_TOKEN";
63
64#[derive(Clone)]
79pub struct DevTools {
80 name: String,
81 version: String,
82 path: String,
83 env: Option<RwsEnv>,
84 token: Option<String>,
85 token_env: String,
86 writes: bool,
87 ops: bool,
88 cache: Option<CacheLayer>,
89 record_requests: bool,
90 allowed_origins: Vec<String>,
91 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
92 database: Option<crate::model::DbPool>,
93 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
94 migrations: Option<String>,
95 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
96 models: Vec<crate::model::ModelInfo>,
97 app_dir: Option<std::path::PathBuf>,
98}
99
100impl Default for DevTools {
101 fn default() -> Self {
102 DevTools::new()
103 }
104}
105
106impl DevTools {
107 pub fn new() -> Self {
111 DevTools {
112 name: "rws-dev".to_string(),
113 version: env!("CARGO_PKG_VERSION").to_string(),
114 path: "/mcp".to_string(),
115 env: None,
116 token: None,
117 token_env: DEFAULT_TOKEN_ENV.to_string(),
118 writes: true,
119 ops: false,
120 cache: None,
121 record_requests: true,
122 allowed_origins: Vec::new(),
123 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
124 database: None,
125 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
126 migrations: None,
127 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
128 models: Vec::new(),
129 app_dir: None,
130 }
131 }
132
133 pub fn server_info(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
136 self.name = name.into();
137 self.version = version.into();
138 self
139 }
140
141 pub fn at(mut self, path: impl Into<String>) -> Self {
144 self.path = path.into();
145 self
146 }
147
148 pub fn env(mut self, env: RwsEnv) -> Self {
150 self.env = Some(env);
151 self
152 }
153
154 pub fn token(mut self, token: impl Into<String>) -> Self {
157 self.token = Some(token.into());
158 self
159 }
160
161 pub fn token_env(mut self, name: impl Into<String>) -> Self {
164 self.token_env = name.into();
165 self
166 }
167
168 pub fn read_only(mut self) -> Self {
171 self.writes = false;
172 self
173 }
174
175 pub fn with_ops(mut self) -> Self {
179 self.ops = true;
180 self
181 }
182
183 pub fn cache(mut self, cache: CacheLayer) -> Self {
187 self.cache = Some(cache);
188 self.ops = true;
189 self
190 }
191
192 pub fn record_requests(mut self, record: bool) -> Self {
197 self.record_requests = record;
198 self
199 }
200
201 pub fn allowed_origins<I, S>(mut self, origins: I) -> Self
205 where
206 I: IntoIterator<Item = S>,
207 S: Into<String>,
208 {
209 self.allowed_origins = origins.into_iter().map(Into::into).collect();
210 self
211 }
212
213 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
215 pub fn database(mut self, pool: crate::model::DbPool) -> Self {
216 self.database = Some(pool);
217 self
218 }
219
220 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
224 pub fn migrations(mut self, dir: impl Into<String>) -> Self {
225 self.migrations = Some(dir.into());
226 self
227 }
228
229 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
235 pub fn model<T: crate::model::Model>(mut self) -> Self {
236 let info = crate::model::ModelInfo::of::<T>();
237 if !self.models.contains(&info) {
238 self.models.push(info);
239 }
240 self
241 }
242
243 pub fn app_dir(mut self, dir: impl Into<std::path::PathBuf>) -> Self {
250 self.app_dir = Some(dir.into());
251 self
252 }
253
254 fn effective_token(&self) -> Option<String> {
257 let token = match &self.token {
258 Some(token) => token.clone(),
259 None => std::env::var(&self.token_env).unwrap_or_default(),
260 };
261 let token = token.trim();
262 (!token.is_empty()).then(|| token.to_string())
263 }
264
265 fn effective_env(&self) -> RwsEnv {
266 self.env.unwrap_or_else(RwsEnv::current)
267 }
268
269 pub fn enabled(&self) -> bool {
272 self.effective_env().is_dev() || self.effective_token().is_some()
273 }
274
275 pub fn register(&self, mcp: McpServer) -> McpServer {
285 let routes = mcp.registered_routes().into_iter().map(|r| (r.method, r.pattern)).collect();
286 let middleware = mcp.middleware_names();
287 self.install(mcp, routes, middleware)
288 }
289
290 fn install(&self, mcp: McpServer, routes: Vec<(String, String)>, middleware: Vec<&'static str>) -> McpServer {
291 let mut mcp = routes_tool(mcp, routes.clone(), middleware);
292 let env = self.effective_env();
293 mcp = mcp.tool(
294 "config",
295 "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",
296 r#"{"type":"object"}"#,
297 move |_| Ok(McpContent::json(config_json(env))),
298 );
299 let dir = self.app_dir.clone().or_else(|| std::env::current_dir().ok()).unwrap_or_default();
300 mcp = layout::install(mcp, dir);
301 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
302 {
303 mcp = models::install(mcp, self.models.clone(), self.database.clone());
304 }
305 mcp = if self.ops {
306 ops::install(mcp, routes, self.cache.clone(), self.writes)
307 } else {
308 recent_tools(mcp)
309 };
310 #[cfg(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))]
311 if let Some(pool) = &self.database {
312 mcp = db::install(mcp, pool.clone(), self.migrations.clone(), self.writes);
313 }
314 mcp.tool_annotations("routes", read_only_hint()).tool_annotations("config", read_only_hint())
315 }
316
317 pub fn mount<A: Application + Send + Sync + 'static>(self, app: A) -> DevMcp<A> {
320 let env = self.effective_env();
321 let token = self.effective_token();
322 if !env.is_dev() && token.is_none() {
323 println!(
324 "dev MCP not mounted: RWS_ENV is {env} and {} is not set (set RWS_ENV=dev, or a token, to enable {}).",
325 self.token_env, self.path
326 );
327 return DevMcp::Off(app);
328 }
329 let routes = app.registered_routes().into_iter().map(|r| (r.method, r.pattern)).collect();
330 let middleware = app.middleware_names();
331 let mut mcp = McpServer::new(self.name.clone(), self.version.clone())
332 .at(self.path.clone())
333 .allowed_origins(self.allowed_origins.clone());
334 match &token {
335 Some(token) => mcp = mcp.require_bearer(token.clone()),
336 None => println!("dev MCP at {} without a token (RWS_ENV=dev): keep the port local, or set {}.", self.path, self.token_env),
337 }
338 mcp = if self.record_requests {
339 mcp.wrap(WithMiddleware::new(app).wrap(crate::request_log::LogLayer))
340 } else {
341 mcp.wrap(app)
342 };
343 let mcp = self.install(mcp, routes, middleware);
344 let audited = crate::admin::access::McpAuditLayer::new(OPS_WRITE_TOOLS.iter().chain(DEV_WRITE_TOOLS.iter()).copied());
346 DevMcp::On(WithMiddleware::new(mcp).wrap(audited))
347 }
348}
349
350#[derive(Clone)]
354pub enum DevMcp<A> {
355 On(WithMiddleware<McpServer>),
358 Off(A),
360}
361
362impl<A> DevMcp<A> {
363 pub fn is_mounted(&self) -> bool {
365 matches!(self, DevMcp::On(_))
366 }
367}
368
369impl<A: Application> Application for DevMcp<A> {
370 fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
371 match self {
372 DevMcp::On(m) => m.execute(request, connection),
373 DevMcp::Off(a) => a.execute(request, connection),
374 }
375 }
376
377 fn runs_inline(&self, request: &Request, connection: &ConnectionInfo) -> bool {
378 match self {
379 DevMcp::On(m) => m.runs_inline(request, connection),
380 DevMcp::Off(a) => a.runs_inline(request, connection),
381 }
382 }
383
384 #[cfg(feature = "http2")]
385 fn execute_async<'a>(&'a self, request: &'a Request, connection: &'a ConnectionInfo) -> Option<crate::application::AsyncResponse<'a>> {
386 match self {
387 DevMcp::On(m) => m.execute_async(request, connection),
388 DevMcp::Off(a) => a.execute_async(request, connection),
389 }
390 }
391
392 fn streams_body(&self, request: &Request, connection: &ConnectionInfo) -> bool {
393 match self {
394 DevMcp::On(m) => m.streams_body(request, connection),
395 DevMcp::Off(a) => a.streams_body(request, connection),
396 }
397 }
398
399 fn registered_routes(&self) -> Vec<crate::router::RouteInfo> {
400 match self {
401 DevMcp::On(m) => m.registered_routes(),
402 DevMcp::Off(a) => a.registered_routes(),
403 }
404 }
405
406 fn middleware_names(&self) -> Vec<&'static str> {
407 match self {
408 DevMcp::On(m) => m.middleware_names(),
409 DevMcp::Off(a) => a.middleware_names(),
410 }
411 }
412}
413
414pub trait DevMcpExt: Application + Sized + Send + Sync + 'static {
417 fn dev_mcp(self) -> DevMcp<Self> {
420 DevTools::new().mount(self)
421 }
422
423 fn dev_mcp_with(self, tools: DevTools) -> DevMcp<Self> {
425 tools.mount(self)
426 }
427}
428
429impl<A: Application + Send + Sync + 'static> DevMcpExt for A {}
430
431pub(crate) fn read_only_hint() -> ToolAnnotations {
434 ToolAnnotations { read_only_hint: Some(true), open_world_hint: Some(false), ..ToolAnnotations::default() }
435}
436
437fn quoted(s: &str) -> String {
438 format!("\"{}\"", json_escape(s))
439}
440
441fn recent_tools(mcp: McpServer) -> McpServer {
444 mcp.tool(
445 "recent_requests",
446 "Last N requests recorded by the server (default 20, max 100)",
447 r#"{"type":"object","properties":{"n":{"type":"integer","description":"Number of entries to return (default 20)"}}}"#,
448 |args| {
449 let n = extract_arg(args, "n").and_then(|v| v.parse::<usize>().ok()).unwrap_or(20);
450 Ok(McpContent::json(crate::admin::ops::recent_json(n, false)))
451 },
452 )
453 .tool(
454 "recent_errors",
455 "Last N requests with a 4xx or 5xx status code (default 20, max 100)",
456 r#"{"type":"object","properties":{"n":{"type":"integer","description":"Number of error 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, true)))
460 },
461 )
462 .tool_annotations("recent_requests", read_only_hint())
463 .tool_annotations("recent_errors", read_only_hint())
464}
465
466fn routes_tool(mcp: McpServer, routes: Vec<(String, String)>, middleware: Vec<&'static str>) -> McpServer {
468 let json = routes_json(&routes, &middleware);
469 mcp.tool(
470 "routes",
471 "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",
472 r#"{"type":"object"}"#,
473 move |_| Ok(McpContent::json(json.clone())),
474 )
475}
476
477pub(crate) fn routes_json(routes: &[(String, String)], middleware: &[&str]) -> String {
478 let routes: Vec<String> =
479 routes.iter().map(|(m, p)| format!(r#"{{"method":{},"pattern":{}}}"#, quoted(m), quoted(p))).collect();
480 let middleware: Vec<String> = middleware.iter().map(|m| quoted(m)).collect();
481 format!(r#"{{"routes":[{}],"middleware":[{}]}}"#, routes.join(","), middleware.join(","))
482}
483
484fn shown_setting(name: &str) -> bool {
486 ["RWS_", "OTEL_", "MCP_"].iter().any(|prefix| name.starts_with(prefix)) || name == "DATABASE_URL"
487}
488
489pub fn masked(name: &str, value: &str) -> String {
493 let shown = crate::entry_point::shown(name, value);
494 hide_url_password(&shown)
495}
496
497fn hide_url_password(value: &str) -> String {
498 let Some(scheme_end) = value.find("://") else { return value.to_string() };
499 let authority_start = scheme_end + 3;
500 let rest = &value[authority_start..];
501 let authority = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
502 let Some(at) = authority.rfind('@') else { return value.to_string() };
503 let Some(colon) = authority[..at].find(':') else { return value.to_string() };
504 format!("{}(hidden){}", &value[..authority_start + colon + 1], &value[authority_start + at..])
505}
506
507pub(crate) fn config_json(env: RwsEnv) -> String {
509 let mut pairs: Vec<(String, String)> = std::env::vars().filter(|(k, _)| shown_setting(k)).collect();
510 pairs.sort();
511 let settings: Vec<String> = pairs.iter().map(|(k, v)| format!("{}:{}", quoted(k), quoted(&masked(k, v)))).collect();
512 format!(
513 r#"{{"rws_env":{},"features":{},"settings":{{{}}}}}"#,
514 quoted(env.as_str()),
515 crate::admin::ops::build_features_json(),
516 settings.join(",")
517 )
518}