You are reading the docs for rws 18.29 The latest release is 18.34. Read this page for the latest release.

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 production

dev_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_ENV is dev, test or prod (the default; anything unrecognised counts as prod). See environment variables.
  • The token is read from MCP_TOKEN, the variable the rws binary's /mcp uses. 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 Origin header, which is refused unless it is listed with DevTools::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/mcp

The 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.

Edit this page on GitHub