slave
A Laravel-inspired Rust framework for MCP-first applications.
Every business operation in a slave app is an
MCP tool served over Streamable HTTP, so AI
agents and your own web apps use the same, single API. The slave CLI
scaffolds the backend (users, roles, OpenID Connect sign-in,
Toasty models and migrations on
PostgreSQL), adds server-rendered web apps (MiniJinja, Tera, Handlebars or
Liquid templates), and serves them all from one process routed by domain or
path (any app can also run as a process of its own, proxied), in the spirit of laravel new, php artisan make:*
and Route::domain().
browser / AI agent
│
▼
runtime (one process) ── config/apps.toml: route by domain and/or path
├──► MCP backend /mcp: auth_*, user_*, role_*, your tools … ← owns the database
└──► apps/storefront theme, server-rendered templates (calls MCP tools in-process)
Why
- One interface. No REST layer next to MCP: the tools an AI agent calls are the same ones your dashboards call.
- Batteries included, security delegated. Users and roles, sign-in
through any OpenID Connect provider (no passwords stored, ID tokens
verified on every call), Toasty models on PostgreSQL with migrations
generated from them, tests that drive
/mcpend to end with a throwaway database per test and a built-in test identity provider. - Pick a template language per app. Server-rendered pages (MiniJinja, Tera, Handlebars or Liquid) for a storefront theme or an admin area; apps reach the backend only through MCP.
- Laravel ergonomics. Generators, optional domain and path routing,
route:list,slave buildthen oneslave servefor everything. - Agent-ready projects. Every new project ships an
AGENTS.md(and aCLAUDE.mdthat imports it) teaching coding agents the architecture, the rules and recipes for adding features.
Install
Requirements: Rust 1.85+. Deploying to Fly.io needs the Fly CLI.
Quick start
&&
Everything runs in one process, the runtime (runtime/). Without
--domain/--path, an app is served directly on its own port (still in that
process). make:app --separate or app:separate <app> runs an app as a
process of its own, proxied by the runtime, to deploy or scale it separately.
MCP backend http://127.0.0.1:3000/mcp
Runtime http://127.0.0.1:8000 (one process)
http://mcp.localhost:8000 → MCP backend
http://<toko>.localhost:8000 → storefront
The first user to sign in through your OpenID Connect provider becomes
admin. MCP clients send the provider's ID token, for example Claude Code:
(with --header "Authorization: Bearer $ID_TOKEN"), or sign in through one
of the apps.
Commands
| Command | Does |
|---|---|
slave new <name> |
Create a project: MCP backend, auth, users, roles, migrations, the runtime, AGENTS.md |
slave build [<component>...] [--release] |
Compile everything, or only backend, runtime or a separate app, e.g. only what changed in CI (--list shows them) |
slave serve [--release] |
Run what was built: the runtime plus each separate app; never compiles |
slave app:separate <app>, app:embed <app> |
Run an app as a process of its own (proxied by the runtime), or back inside it |
slave install:fly [--app <name>] [--region <code>] [--domain <host>] |
Fly.io files for the single runtime: Dockerfile, fly.toml, config/apps.fly.toml |
slave deploy:fly [--secrets <env file>] |
Create the Fly app if needed, stage secrets, check the required ones, fly deploy (migrations as the release command) |
slave make:tools <Name>Tools [--module m] |
MCP tools for a module (src/modules/<m>/tools.rs) |
slave make:model <Name> [--module m] |
A model in src/modules/<m>/models/ |
slave make:migration <name> |
Generate a Toasty migration (SQL) from your model changes |
slave make:app <name> [--domain <host>] [--path </prefix>] [--separate] [--stack minijinja|tera|handlebars|liquid] |
A web app in apps/<name>; direct on its port, or routed by domain/path; in the runtime or separate |
slave migrate, migrate:fresh |
Apply pending migrations; drop everything and re-apply (asks to confirm) |
slave route:list, route:match <host>[/path] |
Inspect domain and path routing |
Database
The backend keeps the platform's own data (users, roles, and what you add)
in PostgreSQL: set DATABASE_URL in .env, create the database once
(createdb <name>, or use a hosted one such as Supabase or Neon), then
slave migrate. Web apps never open it; they call MCP tools. Tests create a
fresh database per test on TEST_DATABASE_URL (default
postgresql://localhost:5432/postgres) and drop it afterwards.
Deploying to Fly.io
install:fly writes a Dockerfile, fly.toml and config/apps.fly.toml
(your routes with localhost moved under --domain). deploy:fly creates
the app, stages the env file as Fly secrets, refuses to deploy while a
required secret is missing, and runs fly deploy; pending migrations run
first (release_command). Machines are stateless, so fly scale count 2
works. Environment on Fly:
| Variable | Required | What |
|---|---|---|
DATABASE_URL |
yes | The platform's PostgreSQL, e.g. postgresql://user:pass@host:5432/db?sslmode=require. Migrations run against it on every deploy (release_command) |
OIDC_ISSUER_URL |
yes | Your OpenID Connect provider (Google, Entra, Auth0, Zitadel, ...) |
OIDC_AUDIENCES |
yes | Comma-separated client IDs whose ID tokens the backend accepts (every app's client ID, plus any MCP client's) |
<APP>_OIDC_CLIENT_ID |
per app that signs users in | The app's client ID at the provider, e.g. STOREFRONT_OIDC_CLIENT_ID |
<APP>_OIDC_CLIENT_SECRET |
if the provider issued one | Confidential client secret |
<APP>_PUBLIC_URL |
no | Fixed public origin for the sign-in callback (otherwise taken from the request) |
MCP_ALLOWED_HOSTS |
no | Only if MCP clients reach the backend directly instead of through a routed host |
APP_NAME, RUST_LOG |
no | Set in fly.toml / the image (info) |
Set by the image, do not override: APP_HOST, APP_PORT, GATEWAY_LISTEN
(0.0.0.0:8080), SLAVE_ROUTES (config/apps.fly.toml) and
SLAVE_TRUSTED_PROXY (1, so apps see https behind Fly's proxy).
At each app's provider, register the redirect URI
https://<its host><its path>/callback.
Routing
config/apps.toml maps requests to apps, like Laravel's Route::domain() and
Route::prefix():
[[]]
= "admin.localhost" # by domain
= "dashboard"
= 4000
[[]]
= "{toko}.localhost" # {toko} reaches the app as X-Slave-Param-Toko
= "storefront"
= 4001
[[]]
= "/console" # by path, on any host (add `host` to restrict)
= "console"
= 4002
The most specific entry wins regardless of order: the longest path prefix,
then the most specific host. Unknown routes get 404, a down app gets 502, and
spoofed X-Slave-* / X-Forwarded-* headers are dropped.
Crates
| Crate | Role |
|---|---|
slave |
This CLI and the project templates |
slave-framework |
Runtime used by generated apps: server lifecycle, config, logging, the Toasty database pool, OpenID Connect sign-in, and test doubles |
slave-gateway |
Domain/path routing for the runtime: apps in-process, a hyper proxy for separate apps |
Repository layout
src/ the slave CLI (commands, generators)
templates/ files written by `slave new` and `slave make:*`
framework/ slave-framework
gateway/ slave-gateway
tests/ end-to-end tests of the CLI and generated projects
Run the tests with cargo test --workspace; cargo test -p slave --test cli -- --ignored also builds a generated project and runs its own test suite.
License
MIT