Skip to main content

mitosis/
mcp.rs

1//! `mitosis mcp`: the clone tools served over MCP on stdio. stdout carries only MCP frames.
2
3use crate::client;
4use crate::envfile;
5use crate::error::{self, ErrorCode, MitosisError};
6use crate::names::{format_duration, parse_duration, parse_wait, validate_name};
7use crate::paths::Home;
8use crate::protocol::{BranchArgs, CloneCreateArgs, CloneInfo, Request, Response};
9use rmcp::handler::server::wrapper::Parameters;
10use rmcp::schemars::JsonSchema;
11use rmcp::{tool, tool_handler, tool_router, ServerHandler, ServiceExt};
12use serde::Deserialize;
13use std::path::PathBuf;
14use std::time::Duration;
15
16#[derive(Deserialize, JsonSchema)]
17#[schemars(crate = "rmcp::schemars")]
18pub struct CloneCreateParams {
19    /// Template to clone. Default "main".
20    pub template: Option<String>,
21    /// Give the clone a stable name (a-z, 0-9, '_' or '-'). Use it when the same database must keep
22    /// its URL across sessions: asking again for a running clone of that name returns it
23    /// (`existing: true`) instead of a new one. Omit for a throwaway clone.
24    pub name: Option<String>,
25    /// How long the clone lives, like 30m, 2h or 1d. Default "2h".
26    pub ttl: Option<String>,
27    /// Keep crash-safe writes (slower). Default false.
28    pub durable: Option<bool>,
29    /// With `name`: destroy the running clone of that name and make a new one from the current
30    /// template, so the data is reset. Default false.
31    pub fresh: Option<bool>,
32    /// A `.env` file to point at the clone: its URL is written there. A relative path is resolved
33    /// against the folder the MCP server runs in. Default: no file is touched.
34    pub env_file: Option<String>,
35    /// Variable to set in `env_file`. Default "DATABASE_URL".
36    pub env_var: Option<String>,
37    /// How long to wait for room when the clone limits are full, like 30s or 2m; "0" fails at
38    /// once. Default: the daemon's setting.
39    pub wait: Option<String>,
40    /// Clone a template built for this git branch, so the database has the branch's schema: the
41    /// template's migrate command runs in the git checkout the MCP server runs in. "" means the
42    /// branch checked out there; it must be the branch checked out. Default: the template itself.
43    pub branch: Option<String>,
44}
45
46#[derive(Deserialize, JsonSchema)]
47#[schemars(crate = "rmcp::schemars")]
48pub struct CloneDestroyParams {
49    /// Clone id or name.
50    pub id_or_name: String,
51}
52
53#[derive(Deserialize, JsonSchema)]
54#[schemars(crate = "rmcp::schemars")]
55pub struct CloneDiffParams {
56    /// Clone id or name.
57    pub id_or_name: String,
58    /// A table (`name` or `schema.name`) to compare row by row; it needs a primary key. Omit for
59    /// the schema changes and row counts of every table.
60    pub table: Option<String>,
61    /// With `table`: rows shown per kind (inserted, deleted, updated). Default 20.
62    pub limit: Option<u32>,
63}
64
65#[derive(Clone)]
66pub struct Mitosis {
67    home: Home,
68}
69
70// The tool macros use the plain `Result`, so the crate alias stays qualified as `error::Result`.
71// Daemon errors arrive as `<message>\nhint: <hint>` (MitosisError's Display), never with a clone URL.
72fn text(r: error::Result<String>) -> Result<String, String> {
73    r.map_err(|e| e.to_string())
74}
75
76// Deliberately not `{r:?}`: a reply can hold a password-bearing URL. Same in `unexpected` in cli.rs.
77fn unexpected() -> MitosisError {
78    MitosisError::new(ErrorCode::ProtocolMismatch, "unexpected daemon reply")
79        .with_hint("restart the daemon: mitosis daemon stop")
80}
81
82#[tool_router(vis = "pub")]
83impl Mitosis {
84    #[tool(
85        description = "Create a private, throwaway Postgres database cloned from a template in about a second. Use it for migrations, seeds and destructive tests. It is deleted when this session ends or the TTL passes. Returns `url`, a stable router URL (postgres://postgres:<password>@127.0.0.1:<router_port>/<clone name or id>) that keeps working when the clone sleeps: an idle clone is stopped to save memory and the next connection to `url` wakes it. `direct_url` is the clone's own port and refuses connections while it sleeps. `branch` clones a template built for a git branch, so the database has that branch's schema. Use clone_diff afterwards to see what you changed."
86    )]
87    async fn clone_create(
88        &self,
89        Parameters(p): Parameters<CloneCreateParams>,
90    ) -> Result<String, String> {
91        text(self.create(p).await)
92    }
93
94    #[tool(
95        description = "List the clones this session created, as JSON. `status` is `ready` or `sleeping`; connecting to a sleeping clone's `url` wakes it."
96    )]
97    async fn clone_list(&self) -> Result<String, String> {
98        text(
99            async {
100                let mine: Vec<CloneInfo> = self
101                    .all_clones()
102                    .await?
103                    .into_iter()
104                    .filter(is_mine)
105                    .collect();
106                Ok(serde_json::to_string_pretty(&mine)?)
107            }
108            .await,
109        )
110    }
111
112    #[tool(
113        description = "Destroy a clone this session created, by id or name. Works on a sleeping clone too. Returns `{\"destroyed\": \"<id>\"}`."
114    )]
115    async fn clone_destroy(
116        &self,
117        Parameters(p): Parameters<CloneDestroyParams>,
118    ) -> Result<String, String> {
119        text(self.destroy(p.id_or_name).await)
120    }
121
122    #[tool(
123        description = "Show what you changed in one of your clones, compared with the template generation it was made from, as JSON: schema changes (tables, columns, indexes, constraints, views, functions, sequences, extensions), row counts per table before and after, and with `table` the inserted, deleted and updated rows by primary key."
124    )]
125    async fn clone_diff(
126        &self,
127        Parameters(p): Parameters<CloneDiffParams>,
128    ) -> Result<String, String> {
129        text(self.diff(p).await)
130    }
131}
132
133/// Clones made through this MCP server name this process as their parent.
134fn is_mine(c: &CloneInfo) -> bool {
135    c.parent_pid == Some(std::process::id() as i32)
136}
137
138/// The clone `id_or_name` names, matched as the daemon does: an id wins over a name.
139fn find<'a>(clones: &'a [CloneInfo], id_or_name: &str) -> Option<&'a CloneInfo> {
140    clones.iter().find(|c| c.id == id_or_name).or_else(|| {
141        clones
142            .iter()
143            .find(|c| c.name.as_deref() == Some(id_or_name))
144    })
145}
146
147impl Mitosis {
148    async fn all_clones(&self) -> error::Result<Vec<CloneInfo>> {
149        match client::request(&self.home, Request::CloneList).await? {
150            Response::Clones { clones } => Ok(clones),
151            _ => Err(unexpected()),
152        }
153    }
154
155    /// Destroys a clone of this session; another session's clone (or a CLI one) is refused.
156    async fn destroy(&self, id_or_name: String) -> error::Result<String> {
157        let clones = self.all_clones().await?;
158        let c = find(&clones, &id_or_name).ok_or_else(|| {
159            MitosisError::new(
160                ErrorCode::CloneNotFound,
161                format!("clone {id_or_name} not found"),
162            )
163            .with_hint("see this session's clones with the clone_list tool")
164        })?;
165        if !is_mine(c) {
166            return Err(MitosisError::new(
167                ErrorCode::InvalidRequest,
168                format!(
169                    "clone {} was not created by this session; it may be another agent's database",
170                    c.id
171                ),
172            )
173            .with_hint("this tool only destroys clones from clone_create in this session; a person can run `mitosis destroy <id>`"));
174        }
175        // By id, so a name reused meanwhile cannot redirect the destroy.
176        match client::request(
177            &self.home,
178            Request::CloneDestroy {
179                id_or_name: c.id.clone(),
180                force: false,
181                forget: false,
182            },
183        )
184        .await?
185        {
186            Response::Destroyed { id } => Ok(serde_json::to_string_pretty(&destroy_output(&id))?),
187            _ => Err(unexpected()),
188        }
189    }
190
191    /// Diffs a clone of this session; another session's clone (or a CLI one) is refused.
192    async fn diff(&self, p: CloneDiffParams) -> error::Result<String> {
193        let clones = self.all_clones().await?;
194        let c = find(&clones, &p.id_or_name).ok_or_else(|| {
195            MitosisError::new(
196                ErrorCode::CloneNotFound,
197                format!("clone {} not found", p.id_or_name),
198            )
199            .with_hint("see this session's clones with the clone_list tool")
200        })?;
201        if !is_mine(c) {
202            return Err(MitosisError::new(
203                ErrorCode::InvalidRequest,
204                format!(
205                    "clone {} was not created by this session; it may be another agent's database",
206                    c.id
207                ),
208            )
209            .with_hint("this tool only reads clones from clone_create in this session"));
210        }
211        // By id, so a name reused meanwhile cannot redirect the diff; the daemon checks the
212        // session again.
213        let req = Request::CloneDiff {
214            id_or_name: c.id.clone(),
215            table: p.table,
216            limit: p.limit,
217            parent_pid: Some(std::process::id() as i32),
218        };
219        match client::request(&self.home, req).await? {
220            Response::Diff(report) => Ok(serde_json::to_string_pretty(&report)?),
221            _ => Err(unexpected()),
222        }
223    }
224
225    async fn create(&self, p: CloneCreateParams) -> error::Result<String> {
226        let template = p.template.unwrap_or_else(|| "main".into());
227        validate_name("template", &template)?;
228        if let Some(n) = &p.name {
229            validate_name("clone", n)?;
230        }
231        let ttl_secs = parse_duration(p.ttl.as_deref().unwrap_or("2h"))?.as_secs();
232        let args = CloneCreateArgs {
233            template: template.clone(),
234            name: p.name,
235            ttl_secs,
236            durable: p.durable.unwrap_or(false),
237            fresh: p.fresh.unwrap_or(false),
238            // Agents must not make undeletable clones.
239            protect: false,
240            env: None,
241            wait_secs: p
242                .wait
243                .as_deref()
244                .map(parse_wait)
245                .transpose()?
246                .map(|d| d.as_secs()),
247            // `create_clone` resolves the branch and the repository root from our cwd.
248            branch: match p.branch {
249                Some(name) => Some(BranchArgs {
250                    name,
251                    repo_root: std::env::current_dir()?,
252                    path: None,
253                }),
254                None => None,
255            },
256            // This process is the parent: when the MCP session ends, the daemon destroys the clone.
257            parent_pid: Some(std::process::id() as i32),
258        };
259        // Also finishes pending `.env` restores first, and resolves a relative path against our cwd.
260        let info =
261            envfile::create_clone(&self.home, args, p.env_file.map(PathBuf::from), p.env_var)
262                .await?;
263        // Warnings are best effort: the clone already exists.
264        let mut warnings: Vec<String> =
265            match client::request(&self.home, Request::TemplateList).await {
266                Ok(Response::Templates { templates }) => templates
267                    .into_iter()
268                    .filter(|t| t.name == info.template)
269                    .flat_map(|t| t.warnings)
270                    .collect(),
271                _ => Vec::new(),
272            };
273        warnings.extend(info.warnings.iter().cloned());
274        Ok(serde_json::to_string_pretty(&create_output(
275            &info, warnings,
276        )?)?)
277    }
278}
279
280/// The `clone_create` reply: the clone, a readable `template_age`, and every warning (the
281/// template's and the clone's).
282pub fn create_output(info: &CloneInfo, warnings: Vec<String>) -> error::Result<serde_json::Value> {
283    let mut out = serde_json::to_value(info)?;
284    if let Some(obj) = out.as_object_mut() {
285        obj.insert(
286            "template_age".into(),
287            format_duration(Duration::from_secs(info.template_age_secs)).into(),
288        );
289        obj.insert("warnings".into(), warnings.into());
290    }
291    Ok(out)
292}
293
294/// The `clone_destroy` reply.
295pub fn destroy_output(id: &str) -> serde_json::Value {
296    serde_json::json!({ "destroyed": id })
297}
298
299#[tool_handler(
300    name = "mitosis",
301    instructions = "Mitosis gives this session private, throwaway Postgres databases, cloned copy-on-write from a template in about a second. clone_create makes one and returns its connection URL (a router URL on 127.0.0.1 that stays valid while the clone sleeps; the next connection wakes it); clone_list shows the clones this session made; clone_destroy deletes one of them; clone_diff shows what you changed in one. Pass `branch` to clone_create to get the schema of a git branch. Clones are deleted when this session ends or their TTL passes."
302)]
303impl ServerHandler for Mitosis {}
304
305/// Serves the clone tools on stdin/stdout until stdin closes.
306pub async fn serve_stdio(home: Home) -> error::Result<()> {
307    let internal = |e: String| MitosisError::new(ErrorCode::Internal, format!("mcp: {e}"));
308    // Restores a dead clone's `.env` value; warnings are best effort and stdout is MCP-only. Only
309    // when a daemon runs: a session that never calls a tool must not start one (clone_create
310    // finishes them anyway).
311    for w in envfile::finish_pending_if_running(&home).await {
312        eprintln!("mitosis: {w}");
313    }
314    let service = Mitosis { home }
315        .serve(rmcp::transport::stdio())
316        .await
317        .map_err(|e| internal(e.to_string()))?;
318    service
319        .waiting()
320        .await
321        .map_err(|e| internal(e.to_string()))?;
322    Ok(())
323}