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).
7//!
8//! Off unless the process runs with `RWS_ENV=dev` ([`RwsEnv`]) or a bearer
9//! token is configured (`MCP_TOKEN` by default, [`DevTools::token`]): in
10//! production without a token [`DevMcpExt::dev_mcp`] mounts nothing and
11//! the app is served as it is. With a token every request to the endpoint
12//! must carry `Authorization: Bearer <token>`
13//! ([`McpServer::require_bearer`]); in development without one the
14//! endpoint is open, so keep the port local or set a token.
15//!
16//! ```rust,no_run
17//! # extern crate rws_proxy as rust_web_server;
18//! use rust_web_server::app::App;
19//! use rust_web_server::core::New;
20//! use rust_web_server::dev_mcp::DevMcpExt;
21//! use rust_web_server::response::Response;
22//!
23//! let app = App::with_state(())
24//!     .get("/hello", |_, _, _, _| Response::new())
25//!     .dev_mcp(); // RWS_ENV=dev: /mcp lists GET /hello
26//! ```
27//!
28//! [`DevTools`] chooses what is mounted: the operations tools of the `rws`
29//! binary too ([`DevTools::with_ops`], also usable alone as [`ops_tools`]),
30//! a database, a migrations directory, read-only mode.
31
32use 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
54/// The tools that change something, besides [`OPS_WRITE_TOOLS`]: the
55/// migration runners (with a `model-*` feature and a migrations
56/// directory).
57pub const DEV_WRITE_TOOLS: [&str; 2] = ["migrations_run", "migrations_rollback_last"];
58
59/// The environment variable a token is read from unless
60/// [`DevTools::token`] or [`DevTools::token_env`] says otherwise — the one
61/// the `rws` binary's `/mcp` uses.
62pub const DEFAULT_TOKEN_ENV: &str = "MCP_TOKEN";
63
64/// What [`DevMcpExt::dev_mcp_with`] mounts, and when.
65///
66/// ```rust,no_run
67/// # extern crate rws_proxy as rust_web_server;
68/// use rust_web_server::app::App;
69/// use rust_web_server::dev_mcp::{DevMcpExt, DevTools};
70///
71/// let app = App::with_state(()).dev_mcp_with(
72///     DevTools::new()
73///         .token_env("APP_DEV_TOKEN") // instead of MCP_TOKEN
74///         .with_ops()                 // the rws binary's 25 tools too
75///         .read_only(),               // but nothing that changes state
76/// );
77/// ```
78#[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    /// The defaults: server `rws-dev`, endpoint `/mcp`, `RWS_ENV` and
108    /// `MCP_TOKEN` from the environment, changing tools included, requests
109    /// recorded for `recent_requests`, no operations tools, no database.
110    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    /// `serverInfo` the endpoint reports (default `rws-dev` and the rws
134    /// version).
135    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    /// The endpoint's path (default `/mcp`) — for an app that serves its
142    /// own MCP server there.
143    pub fn at(mut self, path: impl Into<String>) -> Self {
144        self.path = path.into();
145        self
146    }
147
148    /// Decide as if `RWS_ENV` were `env` instead of reading it.
149    pub fn env(mut self, env: RwsEnv) -> Self {
150        self.env = Some(env);
151        self
152    }
153
154    /// Require this bearer token (and mount the endpoint in any
155    /// environment). An empty or blank token counts as none.
156    pub fn token(mut self, token: impl Into<String>) -> Self {
157        self.token = Some(token.into());
158        self
159    }
160
161    /// Read the token from this environment variable instead of
162    /// `MCP_TOKEN` ([`DEFAULT_TOKEN_ENV`]); [`Self::token`] wins over it.
163    pub fn token_env(mut self, name: impl Into<String>) -> Self {
164        self.token_env = name.into();
165        self
166    }
167
168    /// Leave out every tool that changes something ([`OPS_WRITE_TOOLS`],
169    /// [`DEV_WRITE_TOOLS`]): the agent can look, not act.
170    pub fn read_only(mut self) -> Self {
171        self.writes = false;
172        self
173    }
174
175    /// Mount the `rws` binary's operations tools as well ([`ops_tools`]):
176    /// metrics, rate limits, CORS, static files, maintenance mode, the IP
177    /// blocklist, feature flags, authorization, traffic, config reload.
178    pub fn with_ops(mut self) -> Self {
179        self.ops = true;
180        self
181    }
182
183    /// The response cache for the `cache_stats` / `cache_clear` operations
184    /// tools (a clone of the layer the app wraps; clones share the store).
185    /// Implies [`Self::with_ops`].
186    pub fn cache(mut self, cache: CacheLayer) -> Self {
187        self.cache = Some(cache);
188        self.ops = true;
189        self
190    }
191
192    /// Whether requests to the app are recorded into the request log that
193    /// `recent_requests` / `recent_errors` read (default `true`). Turn it
194    /// off when the app already has a [`crate::request_log::LogLayer`], or
195    /// each request is listed twice.
196    pub fn record_requests(mut self, record: bool) -> Self {
197        self.record_requests = record;
198        self
199    }
200
201    /// Browser origins allowed to call the endpoint
202    /// ([`McpServer::allowed_origins`]); desktop and CLI clients send no
203    /// `Origin` and need none.
204    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    /// The database `db_schema` and the migration tools use.
214    #[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    /// The directory of `*.sql` migrations ([`crate::model::DbPool::migrate`])
221    /// for `migrations` (status), `migrations_run` and
222    /// `migrations_rollback_last`. Needs [`Self::database`].
223    #[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    /// Report the `#[derive(Model)]` type `T` in `models`: its table,
230    /// columns and primary key ([`crate::model::ModelInfo`]), compared with
231    /// the database's table when [`Self::database`] is set. Rust can't list
232    /// a program's models at run time, so each is named once; registering
233    /// one twice lists it once.
234    #[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    /// The project root `layout` reads `rws.app.toml` and the conventional
244    /// paths from (default: the working directory when the tools are
245    /// installed). `env!("CARGO_MANIFEST_DIR")` is the project's own root
246    /// in `cargo run` and `cargo test`, whatever the working directory —
247    /// the starters pass it, since they serve static files from another
248    /// directory.
249    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    /// The token in effect: [`Self::token`], else the token environment
255    /// variable; blank is none.
256    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    /// Whether [`Self::mount`] mounts the endpoint: `RWS_ENV=dev`, or a
270    /// token configured.
271    pub fn enabled(&self) -> bool {
272        self.effective_env().is_dev() || self.effective_token().is_some()
273    }
274
275    /// Register the tools on `mcp`, ungated: `routes`, `config`, `layout`,
276    /// `recent_requests`, `recent_errors`, with a `model-*` feature
277    /// `models`, with a database `db_schema`,
278    /// with migrations `migrations` (and `migrations_run`,
279    /// `migrations_rollback_last` unless [`Self::read_only`]), and the
280    /// operations tools with [`Self::with_ops`]. The routes are those of
281    /// the app `mcp` wraps ([`McpServer::wrap`]). For an app that builds its
282    /// own MCP server and gates it itself; [`Self::mount`] is the gated
283    /// form.
284    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    /// `app` with the endpoint in front of it when [`Self::enabled`];
318    /// otherwise `app` alone, and a one-line notice on stdout.
319    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        // Changes made through the tools land in the admin audit log.
345        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/// An app with the dev MCP endpoint in front of it ([`DevMcp::On`]), or
351/// the app alone ([`DevMcp::Off`]) — what [`DevMcpExt::dev_mcp`] returns.
352/// An [`Application`] either way.
353#[derive(Clone)]
354pub enum DevMcp<A> {
355    /// Mounted: requests to the endpoint go to the tools, everything else
356    /// to the app.
357    On(WithMiddleware<McpServer>),
358    /// Not mounted (production without a token): the app as it was.
359    Off(A),
360}
361
362impl<A> DevMcp<A> {
363    /// `true` when the endpoint is mounted.
364    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
414/// `.dev_mcp()` on every [`Application`] — `AppWithState`, `App`,
415/// `WithMiddleware`, `AsyncAppWithState` and the rest.
416pub trait DevMcpExt: Application + Sized + Send + Sync + 'static {
417    /// The dev MCP endpoint with the default [`DevTools`], in front of
418    /// this app — or this app alone outside development without a token.
419    fn dev_mcp(self) -> DevMcp<Self> {
420        DevTools::new().mount(self)
421    }
422
423    /// The same with the tools and gating `tools` describes.
424    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
431// ── tools shared by the dev and operations sets ──────────────────────────────
432
433pub(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
441/// `recent_requests` and `recent_errors` (both sets have them; registered
442/// once).
443fn 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
466/// `routes`: the app's routes and the middleware around them.
467fn 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
484/// The environment variables `config` shows.
485fn shown_setting(name: &str) -> bool {
486    ["RWS_", "OTEL_", "MCP_"].iter().any(|prefix| name.starts_with(prefix)) || name == "DATABASE_URL"
487}
488
489/// `value` as `config` shows it: [`crate::entry_point::shown`] (secrets by
490/// name, PKCS#11 PINs), and a password in a URL's user info
491/// (`postgres://app:pw@db/app`) hidden too.
492pub 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
507/// What `config` answers: `{"rws_env":…,"features":{…},"settings":{…}}`.
508pub(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}