Let a coding agent inspect your app
app.dev_mcp() mounts an MCP endpoint with your app's routes, middleware, configuration, recent errors, database schema and migrations, for Claude Code or Cursor while you develop.
A coding agent working on an rws app can read the code, but not what the
running app actually registered, which settings it ended up with, or why the
last request failed. app.dev_mcp() (since 18.28.0) puts an MCP endpoint in
front of the app that answers those questions: its routes and the
middleware around them, its effective configuration with secrets hidden, the
last requests and errors, and — with a model-* feature — its database
schema and migrations.
use rust_web_server::app::App;
use rust_web_server::dev_mcp::DevMcpExt;
use rust_web_server::server::Server;
let app = App::with_state(state)
.get("/api/items/:id", get_item)
.post("/api/items", create_item)
.wrap(CsrfLayer::new())
.dev_mcp(); // /mcp in development, nothing in productiondev_mcp() works on every application type (AppWithState, App,
WithMiddleware, AsyncAppWithState, …) and returns one you serve as
before. Every path but /mcp still reaches the app.
When it is mounted
RWS_ENV |
token set | /mcp |
|---|---|---|
dev |
no | mounted, no authentication |
| any | yes | mounted, requires Authorization: Bearer <token> |
test, prod, unset |
no | not mounted (one line on stdout says so) |
RWS_ENVisdev,testorprod(the default; anything unrecognised counts asprod). See environment variables.- The token is read from
MCP_TOKEN, the variable therwsbinary's/mcpuses.DevTools::token(…)sets it in code,DevTools::token_env("APP_DEV_TOKEN")reads another variable. - In development without a token anyone who can reach the port can call the
tools, including the ones that change things: keep the port on
127.0.0.1(RWS_CONFIG_IP) or set a token. - Browser-based clients send an
Originheader, which is refused unless it is listed withDevTools::allowed_origins([…]). CLI and desktop clients send none. - Calls to the changing tools are recorded in the admin audit log, as for the binary.
Connect the agent while the app runs:
RWS_ENV=dev RWS_CONFIG_IP=127.0.0.1 cargo run
claude mcp add --transport http my-app http://localhost:7878/mcpThe tools
| Tool | Answers |
|---|---|
routes |
Each route's method and pattern, in registration order, and the middleware layers around them in the order a request passes them: {"routes":[{"method":"GET","pattern":"/api/items/:id"}],"middleware":["CsrfLayer"]} |
config |
RWS_ENV, the compiled-in features and every RWS_*, OTEL_* and MCP_* setting in effect (config file and flags included). Values of settings named *SECRET*, *PASSWORD*, *TOKEN*, PKCS#11 PINs and passwords inside URLs (postgres://app:(hidden)@db/app) are hidden |
layout |
The app's name, its rws.app.toml ("manifest":null without one — the conventional layout), and for each part (main, lib, routes, models, jobs, migrations, templates, tests, static) its path, whether it exists and the names in it; a manifest that doesn't parse is reported in error. Read on every call, so new files show at once |
recent_requests {"n": 50} |
The last requests (at most 100): method, path, status, client IP, latency |
recent_errors {"n": 50} |
The last 4xx/5xx |
models |
With a model-* feature: each type registered with DevTools::model::<T>() — type, table, columns, primary key; with DevTools::database(pool) also each column's type there, table_exists, missing_columns (model columns the table lacks) and unmapped_columns (table columns no field maps) |
db_schema |
With DevTools::database(pool): every table and its columns (name, type, nullable, default, primary key) — SQLite, PostgreSQL (the current schema) and MySQL (the current database) |
migrations |
With DevTools::migrations(dir) too: each migration file, whether it is applied and whether it has a .down.sql |
migrations_run |
Apply the pending migrations; answers the status afterwards |
migrations_rollback_last |
Undo the last applied migration; answers which (null when none) |
gen_model, gen_route, gen_migration, gen_test |
The generators: write a model, routes, a migration or a test into the project, or with dry_run answer the plan |
The middleware listed is the stack around the app's routes (each layer's
Middleware::layer_name(), by default its type name); rws has no per-route
middleware to report. Routes come from Application::registered_routes(): the
built-in controllers behind an AppWithState (static files, /healthz, …)
aren't listed.
dev_mcp() records the app's requests for recent_requests itself. If the
app already has a LogLayer, pass .record_requests(false) so each request
is listed once.
layout reads the working directory unless DevTools::app_dir(dir) names
the project root; pass env!("CARGO_MANIFEST_DIR") when the app serves
static files from another directory (it is the project root in cargo run
and cargo test).
Rust can't list a program's #[derive(Model)] types at run time, so
models reports the ones you name:
let tools = DevTools::new()
.model::<User>()
.model::<Post>()
.database(pool.clone()); // compare each with its table
{"database":true,"models":[{"type":"app::models::User","name":"User","table":"users",
"primary_key":"id","auto_increment":true,
"columns":[{"name":"id","primary_key":true,"db":{"type":"INTEGER","nullable":false,"primary_key":true}},
{"name":"email","primary_key":false,"db":null}],
"table_exists":true,"missing_columns":["email"],"unmapped_columns":[]}],"hint":null}missing_columns is what a migration has yet to add. The same description
is model::ModelInfo::of::<T>() in code.
Generators
gen_model, gen_route, gen_migration and gen_test are
rws gen as tools (18.29.0): the agent asks
for a model, a resource's routes, a migration or a test and gets code pinned
to the release's API, written into the project layout reports (the
app_dir). They change files, so .read_only() leaves them out, as it does
migrations_run.
| Tool | Arguments |
|---|---|
gen_model |
name (Post), fields (["title:String", "views:i64", "note:Option<String>"]), db (sqlite, postgres, mysql; default from Cargo.toml), dry_run |
gen_route |
resource (posts), fields, methods (["list", "show", "create", "update", "remove"], default all), prefix (/api), dry_run |
gen_migration |
name (add_index_to_posts), dry_run |
gen_test |
resource, app_expr (app(Store::memory()) when app takes arguments), dry_run |
Each answers the plan as JSON — the same as rws gen --json:
{"command":"rws gen migration add_index","status":"written","written":true,
"changes":[{"path":"migrations/0001_add_index.sql","action":"create","diff":"--- /dev/null\n+++ b/migrations/0001_add_index.sql\n…"}],
"notes":[]}dry_run: true answers "status":"planned" and writes nothing. Nothing is
overwritten: a file in the way makes the call a tool error whose text is the
same JSON with "status":"conflict" and the diff; a bad name or type is a
tool error with "status":"usage".
Choosing what is mounted
dev_mcp_with(DevTools) takes the options:
use rust_web_server::dev_mcp::{DevMcpExt, DevTools};
use rust_web_server::model::DbPool;
let pool = DbPool::from_env().await?;
let app = routes
.dev_mcp_with(
DevTools::new()
.database(pool.clone())
.migrations("migrations")
.with_ops() // also the rws binary's 25 operations tools
.read_only(), // but none of the tools that change something
);| Method | Does |
|---|---|
.database(pool) / .migrations(dir) |
The database tools (model-sqlite, model-postgres or model-mysql) |
.model::<T>() |
List the #[derive(Model)] type T in models (once per type) |
.app_dir(dir) |
The project root layout reads (default: the working directory) |
.with_ops() |
The operations tools as well: metrics, rate limits, maintenance mode, the IP blocklist, feature flags, authorization, traffic, config reload |
.cache(layer) |
The cache_stats / cache_clear operations tools for this response cache (a clone of the layer the app wraps); implies .with_ops() |
.read_only() |
Leaves out every tool that changes something (migrations_run, migrations_rollback_last, the generators in dev_mcp::GEN_TOOLS and the operations tools in dev_mcp::OPS_WRITE_TOOLS) |
.token(t) / .token_env(name) |
The bearer token, or the variable it is read from (default MCP_TOKEN) |
.env(RwsEnv::Dev) |
Decide as if RWS_ENV were this (tests) |
.at("/_dev/mcp") |
Another path, for an app that serves its own MCP server at /mcp |
.server_info(name, version) |
What initialize reports (default rws-dev) |
.record_requests(false) |
Don't record requests (the app has a LogLayer) |
An app that builds its own McpServer and gates it itself can add the same
tools with DevTools::new().register(server) — the routes are those of the
app the server wraps. dev_mcp::ops_tools(server, routes, cache) adds only
the operations tools; it is what the rws binary mounts.
Testing it
The tools are ordinary MCP tools, so TestClient drives them:
use rust_web_server::dev_mcp::{DevMcpExt, DevTools, RwsEnv};
use rust_web_server::test_client::TestClient;
let client = TestClient::new(app().dev_mcp_with(DevTools::new().env(RwsEnv::Dev)));
let res = client
.post("/mcp")
.body_json(r#"{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"routes","arguments":{}}}"#)
.send();
assert!(res.body_text().contains("/api/items/:id"));Each starter has a tests/dev_mcp.rs that
calls initialize, tools/list and then every listed tool, failing on any
JSON-RPC or tool error (the generators with dry_run, since they need
arguments); CI runs it for every starter.
Not yet
Jobs, and the same data as MCP resources, are planned.