Skip to main content

rws_proxy/dev_mcp/
mod.rs

1//! `app.dev_mcp()`: an MCP endpoint that lets a coding agent look inside
2//! the application it is working on — its routes and the middleware
3//! around them, its effective configuration (secrets hidden), its layout
4//! and `rws.app.toml` ([`crate::app_manifest`]), the last requests and
5//! errors, and with a `model-*` feature its models, database schema and
6//! migrations. Since 18.28.0 (roadmap #85, #87). Since 18.29.0 (#88) it
7//! also writes code: `gen_model`, `gen_route`, `gen_migration`,
8//! `gen_test` ([`GEN_TOOLS`], [`crate::generator`]), unless read-only.
9//!
10//! Off unless the process runs with `RWS_ENV=dev` ([`RwsEnv`]) or a bearer
11//! token is configured (`MCP_TOKEN` by default, [`DevTools::token`]): in
12//! production without a token [`DevMcpExt::dev_mcp`] mounts nothing and
13//! the app is served as it is. With a token every request to the endpoint
14//! must carry `Authorization: Bearer <token>`
15//! ([`McpServer::require_bearer`]); in development without one the
16//! endpoint is open, so keep the port local or set a token.
17//!
18//! ```rust,no_run
19//! # extern crate rws_proxy as rust_web_server;
20//! use rust_web_server::app::App;
21//! use rust_web_server::core::New;
22//! use rust_web_server::dev_mcp::DevMcpExt;
23//! use rust_web_server::response::Response;
24//!
25//! let app = App::with_state(())
26//!     .get("/hello", |_, _, _, _| Response::new())
27//!     .dev_mcp(); // RWS_ENV=dev: /mcp lists GET /hello
28//! ```
29//!
30//! [`DevTools`] chooses what is mounted: the operations tools of the `rws`
31//! binary too ([`DevTools::with_ops`], also usable alone as [`ops_tools`]),
32//! a database, a migrations directory, read-only mode.
33
34use 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
58/// The tools that change something, besides [`OPS_WRITE_TOOLS`]: the
59/// migration runners (with a `model-*` feature and a migrations
60/// directory).
61pub const DEV_WRITE_TOOLS: [&str; 2] = ["migrations_run", "migrations_rollback_last"];
62
63/// The environment variable a token is read from unless
64/// [`DevTools::token`] or [`DevTools::token_env`] says otherwise — the one
65/// the `rws` binary's `/mcp` uses.
66pub const DEFAULT_TOKEN_ENV: &str = "MCP_TOKEN";
67
68/// What [`DevMcpExt::dev_mcp_with`] mounts, and when.
69///
70/// ```rust,no_run
71/// # extern crate rws_proxy as rust_web_server;
72/// use rust_web_server::app::App;
73/// use rust_web_server::dev_mcp::{DevMcpExt, DevTools};
74///
75/// let app = App::with_state(()).dev_mcp_with(
76///     DevTools::new()
77///         .token_env("APP_DEV_TOKEN") // instead of MCP_TOKEN
78///         .with_ops()                 // the rws binary's 25 tools too
79///         .read_only(),               // but nothing that changes state
80/// );
81/// ```
82#[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    /// The defaults: server `rws-dev`, endpoint `/mcp`, `RWS_ENV` and
112    /// `MCP_TOKEN` from the environment, changing tools included, requests
113    /// recorded for `recent_requests`, no operations tools, no database.
114    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    /// `serverInfo` the endpoint reports (default `rws-dev` and the rws
138    /// version).
139    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    /// The endpoint's path (default `/mcp`) — for an app that serves its
146    /// own MCP server there.
147    pub fn at(mut self, path: impl Into<String>) -> Self {
148        self.path = path.into();
149        self
150    }
151
152    /// Decide as if `RWS_ENV` were `env` instead of reading it.
153    pub fn env(mut self, env: RwsEnv) -> Self {
154        self.env = Some(env);
155        self
156    }
157
158    /// Require this bearer token (and mount the endpoint in any
159    /// environment). An empty or blank token counts as none.
160    pub fn token(mut self, token: impl Into<String>) -> Self {
161        self.token = Some(token.into());
162        self
163    }
164
165    /// Read the token from this environment variable instead of
166    /// `MCP_TOKEN` ([`DEFAULT_TOKEN_ENV`]); [`Self::token`] wins over it.
167    pub fn token_env(mut self, name: impl Into<String>) -> Self {
168        self.token_env = name.into();
169        self
170    }
171
172    /// Leave out every tool that changes something ([`OPS_WRITE_TOOLS`],
173    /// [`DEV_WRITE_TOOLS`], the generators [`GEN_TOOLS`]): the agent can
174    /// look, not act.
175    pub fn read_only(mut self) -> Self {
176        self.writes = false;
177        self
178    }
179
180    /// Mount the `rws` binary's operations tools as well ([`ops_tools`]):
181    /// metrics, rate limits, CORS, static files, maintenance mode, the IP
182    /// blocklist, feature flags, authorization, traffic, config reload.
183    pub fn with_ops(mut self) -> Self {
184        self.ops = true;
185        self
186    }
187
188    /// The response cache for the `cache_stats` / `cache_clear` operations
189    /// tools (a clone of the layer the app wraps; clones share the store).
190    /// Implies [`Self::with_ops`].
191    pub fn cache(mut self, cache: CacheLayer) -> Self {
192        self.cache = Some(cache);
193        self.ops = true;
194        self
195    }
196
197    /// Whether requests to the app are recorded into the request log that
198    /// `recent_requests` / `recent_errors` read (default `true`). Turn it
199    /// off when the app already has a [`crate::request_log::LogLayer`], or
200    /// each request is listed twice.
201    pub fn record_requests(mut self, record: bool) -> Self {
202        self.record_requests = record;
203        self
204    }
205
206    /// Browser origins allowed to call the endpoint
207    /// ([`McpServer::allowed_origins`]); desktop and CLI clients send no
208    /// `Origin` and need none.
209    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    /// The database `db_schema` and the migration tools use.
219    #[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    /// The directory of `*.sql` migrations ([`crate::model::DbPool::migrate`])
226    /// for `migrations` (status), `migrations_run` and
227    /// `migrations_rollback_last`. Needs [`Self::database`].
228    #[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    /// Report the `#[derive(Model)]` type `T` in `models`: its table,
235    /// columns and primary key ([`crate::model::ModelInfo`]), compared with
236    /// the database's table when [`Self::database`] is set. Rust can't list
237    /// a program's models at run time, so each is named once; registering
238    /// one twice lists it once.
239    #[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    /// The project root `layout` reads `rws.app.toml` and the conventional
249    /// paths from (default: the working directory when the tools are
250    /// installed). `env!("CARGO_MANIFEST_DIR")` is the project's own root
251    /// in `cargo run` and `cargo test`, whatever the working directory —
252    /// the starters pass it, since they serve static files from another
253    /// directory.
254    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    /// The token in effect: [`Self::token`], else the token environment
260    /// variable; blank is none.
261    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    /// Whether [`Self::mount`] mounts the endpoint: `RWS_ENV=dev`, or a
275    /// token configured.
276    pub fn enabled(&self) -> bool {
277        self.effective_env().is_dev() || self.effective_token().is_some()
278    }
279
280    /// Register the tools on `mcp`, ungated: `routes`, `config`, `layout`,
281    /// `recent_requests`, `recent_errors`, the generators ([`GEN_TOOLS`],
282    /// unless [`Self::read_only`]), with a `model-*` feature
283    /// `models`, with a database `db_schema`,
284    /// with migrations `migrations` (and `migrations_run`,
285    /// `migrations_rollback_last` unless [`Self::read_only`]), and the
286    /// operations tools with [`Self::with_ops`]. The routes are those of
287    /// the app `mcp` wraps ([`McpServer::wrap`]). For an app that builds its
288    /// own MCP server and gates it itself; [`Self::mount`] is the gated
289    /// form.
290    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    /// `app` with the endpoint in front of it when [`Self::enabled`];
327    /// otherwise `app` alone, and a one-line notice on stdout.
328    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        // Changes made through the tools land in the admin audit log.
354        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/// An app with the dev MCP endpoint in front of it ([`DevMcp::On`]), or
360/// the app alone ([`DevMcp::Off`]) — what [`DevMcpExt::dev_mcp`] returns.
361/// An [`Application`] either way.
362#[derive(Clone)]
363pub enum DevMcp<A> {
364    /// Mounted: requests to the endpoint go to the tools, everything else
365    /// to the app.
366    On(WithMiddleware<McpServer>),
367    /// Not mounted (production without a token): the app as it was.
368    Off(A),
369}
370
371impl<A> DevMcp<A> {
372    /// `true` when the endpoint is mounted.
373    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
423/// `.dev_mcp()` on every [`Application`] — `AppWithState`, `App`,
424/// `WithMiddleware`, `AsyncAppWithState` and the rest.
425pub trait DevMcpExt: Application + Sized + Send + Sync + 'static {
426    /// The dev MCP endpoint with the default [`DevTools`], in front of
427    /// this app — or this app alone outside development without a token.
428    fn dev_mcp(self) -> DevMcp<Self> {
429        DevTools::new().mount(self)
430    }
431
432    /// The same with the tools and gating `tools` describes.
433    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
440// ── tools shared by the dev and operations sets ──────────────────────────────
441
442pub(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
450/// `recent_requests` and `recent_errors` (both sets have them; registered
451/// once).
452fn 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
475/// `routes`: the app's routes and the middleware around them.
476fn 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
493/// The environment variables `config` shows.
494fn shown_setting(name: &str) -> bool {
495    ["RWS_", "OTEL_", "MCP_"].iter().any(|prefix| name.starts_with(prefix)) || name == "DATABASE_URL"
496}
497
498/// `value` as `config` shows it: [`crate::entry_point::shown`] (secrets by
499/// name, PKCS#11 PINs), and a password in a URL's user info
500/// (`postgres://app:pw@db/app`) hidden too.
501pub 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
516/// What `config` answers: `{"rws_env":…,"features":{…},"settings":{…}}`.
517pub(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}