chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

1054 lines · 36.1 KB Raw History
//! An MCP server for knotless repositories.
//!
//! Agents read files, trees, history, diffs and search results straight from
//! a repository's records, with no clone, and commit with no checkout. The
//! same tools create repositories and decide who may read and push them.
//! Everything runs as the account `knotless login` last logged in, or as
//! `KNOTLESS_IDENTIFIER`.

use knotless::commit::{self, Change, Spec};
use knotless::env::Ctx;
use knotless::forge;
use knotless::gateway;
use knotless::git::Git;
use knotless::manage::{self, NewRepo};
use knotless::mirror::Mirror;
use knotless::moderation::{self, Admin};
use knotless::read;
use knotless::remote::Remote;
use knotless::url::RepoUrl;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, ContentBlock};
use rmcp::{ErrorData as McpError, ServerHandler, schemars, tool, tool_handler, tool_router};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

#[derive(Clone)]
pub struct Server {
    ctx: Ctx,
}

/// A repository as `handle/name`, `did/name`, or a `knotless://` URL.
fn repo_url(repo: &str) -> anyhow::Result<RepoUrl> {
    let repo = repo.trim();
    if repo.starts_with("knotless://") {
        RepoUrl::parse(repo)
    } else {
        RepoUrl::parse(&format!("knotless://{repo}"))
    }
}

/// What a report or takedown is about: an account as `handle` or DID, a
/// repository as `handle/name` or a `knotless://` URL, or a record by its
/// `at://` URI.
fn target(subject: &str) -> String {
    let subject = subject.trim();
    match subject.contains('/') && !subject.contains("://") {
        true => format!("knotless://{subject}"),
        false => subject.to_string(),
    }
}

/// A tool's answer: JSON, or an error the model can read and act on.
fn answer(res: anyhow::Result<Value>) -> Result<CallToolResult, McpError> {
    Ok(match res {
        Ok(Value::String(text)) => CallToolResult::success(vec![ContentBlock::text(text)]),
        Ok(value) => CallToolResult::success(vec![ContentBlock::text(value.to_string())]),
        Err(err) => CallToolResult::error(vec![ContentBlock::text(format!("{err:#}"))]),
    })
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Owner {
    /// A handle or DID.
    pub owner: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Repo {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct At {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// A branch, tag or commit. Defaults to the default branch.
    pub rev: Option<String>,
    /// A path in the repository. Defaults to the root.
    pub path: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct File {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    pub path: String,
    /// A branch, tag or commit. Defaults to the default branch.
    pub rev: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Search {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// A fixed string, matched case-insensitively.
    pub pattern: String,
    pub rev: Option<String>,
    /// At most this many matches. Defaults to 100.
    pub limit: Option<usize>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct History {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    pub rev: Option<String>,
    /// Only commits that touch this path.
    pub path: Option<String>,
    /// At most this many commits. Defaults to 20.
    pub limit: Option<usize>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Compare {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    pub base: String,
    pub head: String,
}

#[derive(Deserialize, Serialize, schemars::JsonSchema)]
pub struct FileChange {
    pub path: String,
    /// The new content, as text.
    pub content: Option<String>,
    /// The new content, base64, for binary files.
    pub base64: Option<String>,
    /// Delete the file rather than write it.
    #[serde(default)]
    pub delete: bool,
    #[serde(default)]
    pub executable: bool,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct CommitParams {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    pub branch: String,
    pub message: String,
    pub changes: Vec<FileChange>,
    /// The commit these changes were made against. If the branch has moved
    /// on and changed a path these touch, the commit fails as a conflict
    /// instead of overwriting it.
    pub base: Option<String>,
    /// As the owner, go through the repository's gateway so concurrent
    /// commits to the branch are batched. Collaborators always do.
    #[serde(default)]
    pub batch: bool,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Delegate {
    /// The gateway's DID; the appview's when left out.
    pub gateway: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct NewBranch {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// Without refs/heads/.
    pub branch: String,
    /// A commit or branch to start it at; the default branch when left out.
    pub from: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct OldBranch {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// Without refs/heads/.
    pub branch: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Create {
    /// The new repository's name, in your account.
    pub name: String,
    /// Readable only by the accounts it is shared with.
    #[serde(default)]
    pub private: bool,
    pub description: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Rename {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// The new name.
    pub name: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Transfer {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// The account to offer it to, as `handle` (keeping its name) or
    /// `handle/name`. Leave out to withdraw an offer.
    pub to: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Hide {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// The `at://` URI of a pull, issue or comment on it.
    pub uri: String,
    /// `false` shows it again. Hides by default.
    pub hidden: Option<bool>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Block {
    /// The handle or DID to block.
    pub account: String,
    /// Only from this repository (`handle/name`); from all of yours when
    /// left out.
    pub repo: Option<String>,
    /// `false` unblocks. Blocks by default.
    pub blocked: Option<bool>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Report {
    /// An account (`handle`), a repository (`handle/name`) or a pull, issue
    /// or comment (`at://` URI).
    pub subject: String,
    /// spam, violation, misleading, sexual, rude or other.
    pub reason: String,
    /// What the moderators should know.
    pub text: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Takedown {
    /// An account (`handle`), a repository (`handle/name`) or a pull, issue
    /// or comment (`at://` URI).
    pub subject: String,
    /// `false` puts it back. Takes it down by default.
    pub applied: Option<bool>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Moderated {
    /// An account (`handle`), a repository (`handle/name`) or a pull, issue
    /// or comment (`at://` URI).
    pub subject: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Reports {
    /// How many, newest first. 50 by default.
    pub limit: Option<usize>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Protect {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// The branch, without refs/heads/.
    pub branch: String,
    /// Let merge commits in. Without it the branch stays linear, and pulls
    /// merge into it squashed.
    #[serde(default)]
    pub merges: bool,
    /// Stop protecting it instead.
    #[serde(default)]
    pub remove: bool,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Share {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// The handle or DID to share with.
    pub with: String,
    /// Let them push, not just read. Pushes go through the gateway the
    /// owner has delegated writes to.
    #[serde(default)]
    pub write: bool,
    /// Take access away instead.
    #[serde(default)]
    pub revoke: bool,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Fork {
    /// The repository to fork: `handle/name`, `did/name` or a `knotless://` URL.
    pub repo: String,
    /// The fork's name in your account. Defaults to the repository's.
    pub name: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct OpenPull {
    /// The repository to merge into.
    pub repo: String,
    /// The branch to merge into.
    pub base: String,
    /// The repository the branch is in: the same one, or a fork.
    pub head_repo: String,
    pub head_branch: String,
    pub title: String,
    pub body: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct OpenIssue {
    pub repo: String,
    pub title: String,
    pub body: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Items {
    pub repo: String,
    /// `pull` or `issue`. Both when absent.
    pub kind: Option<String>,
    /// `open`, `closed` or `merged`. Every state when absent.
    pub state: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Subject {
    pub repo: String,
    /// The pull's or issue's at:// URI.
    pub uri: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct RenameBranch {
    /// `handle/name`, `did/name` or `knotless://handle/name`.
    pub repo: String,
    /// The branch's name now, without refs/heads/.
    pub from: String,
    /// Its new name.
    pub to: String,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Retarget {
    pub repo: String,
    /// The pull's at:// URI.
    pub uri: String,
    /// The branch to merge into from now on.
    pub base: Option<String>,
    /// The branch to merge from, in the same repository as before; the
    /// pull's author only.
    pub head: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct Remark {
    pub repo: String,
    /// The pull's or issue's at:// URI.
    pub uri: String,
    pub body: String,
    /// `approve` or `changes`, to review a pull.
    pub verdict: Option<String>,
}

#[derive(Deserialize, schemars::JsonSchema)]
pub struct SetState {
    pub repo: String,
    /// The pull's or issue's at:// URI.
    pub uri: String,
    /// `open` or `closed`.
    pub state: String,
}

impl Server {
    pub fn new(ctx: Ctx) -> Server {
        Server { ctx }
    }

    /// A synced mirror of `repo`, to read from.
    async fn mirror(&self, repo: &str) -> anyhow::Result<Git> {
        let url = repo_url(repo)?;
        let (remote, snap) = Remote::locate(&self.ctx, &url).await?;
        let Some(key) = snap.key() else {
            anyhow::bail!("{url}: no such repository");
        };
        let mirror = Mirror::open(&self.ctx, remote.did(), key).await?;
        mirror.sync(&remote, &snap).await?;
        Ok(mirror.git)
    }
}

#[tool_router]
impl Server {
    #[tool(
        description = "List an account's repositories: its public ones, and your own private ones too when the account is yours."
    )]
    async fn list_repos(
        &self,
        Parameters(p): Parameters<Owner>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let repos = manage::list(&self.ctx, &p.owner).await?;
                Ok(serde_json::to_value(repos)?)
            }
            .await,
        )
    }

    #[tool(description = "A repository's branches and tags, with the commit each points at.")]
    async fn refs(&self, Parameters(p): Parameters<Repo>) -> Result<CallToolResult, McpError> {
        answer(async {
            let url = repo_url(&p.repo)?;
            let (remote, snap) = Remote::locate(&self.ctx, &url).await?;
            let Some(repo) = &snap.repo else {
                anyhow::bail!("{url}: no such repository");
            };
            Ok(json!({
                "defaultBranch": repo.value.default_branch,
                "private": remote.space.is_some(),
                "refs": snap.live_refs().into_iter().map(|(name, sha)| json!({"name": name, "sha": sha})).collect::<Vec<_>>(),
            }))
        }
        .await)
    }

    #[tool(description = "List a directory: names, kinds (blob or tree), modes and sizes.")]
    async fn list_tree(&self, Parameters(p): Parameters<At>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let git = self.mirror(&p.repo).await?;
                let rev = read::rev(p.rev.as_deref())?;
                let value = read::tree(&git, &rev, &read::path(p.path.as_deref())).await?;
                Ok(serde_json::to_value(value)?)
            }
            .await,
        )
    }

    #[tool(description = "Read a file. Text comes back as is; binary files as base64.")]
    async fn read_file(&self, Parameters(p): Parameters<File>) -> Result<CallToolResult, McpError> {
        answer(async {
            let git = self.mirror(&p.repo).await?;
            let rev = read::rev(p.rev.as_deref())?;
            let (_, bytes) = read::blob(&git, &rev, &read::path(Some(&p.path))).await?;
            Ok(match String::from_utf8(bytes) {
                Ok(text) => Value::String(text),
                Err(err) => {
                    use base64::Engine;
                    json!({"base64": base64::engine::general_purpose::STANDARD.encode(err.into_bytes())})
                }
            })
        }
        .await)
    }

    #[tool(
        description = "Search the files at a revision for a fixed string, case-insensitively. Returns path, line number and line."
    )]
    async fn search(&self, Parameters(p): Parameters<Search>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let git = self.mirror(&p.repo).await?;
                let rev = read::rev(p.rev.as_deref())?;
                let value = read::grep(&git, &rev, &p.pattern, p.limit.unwrap_or(100)).await?;
                Ok(serde_json::to_value(value)?)
            }
            .await,
        )
    }

    #[tool(description = "Commit history, newest first, optionally only commits touching a path.")]
    async fn log(&self, Parameters(p): Parameters<History>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let git = self.mirror(&p.repo).await?;
                let rev = read::rev(p.rev.as_deref())?;
                let path = read::path(p.path.as_deref());
                let value = read::log(&git, &rev, &path, p.limit.unwrap_or(20)).await?;
                Ok(serde_json::to_value(value)?)
            }
            .await,
        )
    }

    #[tool(
        description = "A unified diff of what head changed since it forked from base (base...head)."
    )]
    async fn diff(&self, Parameters(p): Parameters<Compare>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let git = self.mirror(&p.repo).await?;
                let (base, head) = (read::rev(Some(&p.base))?, read::rev(Some(&p.head))?);
                Ok(Value::String(read::diff(&git, &base, &head).await?))
            }
            .await,
        )
    }

    #[tool(
        description = "Create a branch at a commit or another branch, or at the default branch. The owner and collaborators may."
    )]
    async fn create_branch(
        &self,
        Parameters(p): Parameters<NewBranch>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let url = repo_url(&p.repo)?;
                let sha =
                    forge::branch(&self.ctx, &url, &p.branch, p.from.as_deref(), false).await?;
                Ok(json!({"branch": p.branch, "sha": sha}))
            }
            .await,
        )
    }

    #[tool(
        description = "Delete a branch, such as one already merged. Not the default branch. The owner and collaborators may."
    )]
    async fn delete_branch(
        &self,
        Parameters(p): Parameters<OldBranch>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let url = repo_url(&p.repo)?;
                forge::branch(&self.ctx, &url, &p.branch, None, true).await?;
                Ok(json!({"deleted": p.branch}))
            }
            .await,
        )
    }

    #[tool(
        description = "Commit file changes to a branch without a checkout, creating the branch or repository if needed: a new branch starts from `base`, or else from the default branch. If the branch moved meanwhile, the changes are replayed onto its new tip, unless `base` says what they were made against and a path they touch changed since. The commit is authored by you."
    )]
    async fn commit(
        &self,
        Parameters(p): Parameters<CommitParams>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                use base64::Engine;
                let mut changes = Vec::new();
                for c in p.changes {
                    changes.push(match (c.delete, c.content, c.base64) {
                        (true, None, None) => Change::Delete { path: c.path },
                        (false, Some(text), None) => Change::Put {
                            path: c.path,
                            data: text.into_bytes(),
                            executable: c.executable,
                        },
                        (false, None, Some(b64)) => Change::Put {
                            path: c.path,
                            data: base64::engine::general_purpose::STANDARD.decode(b64)?,
                            executable: c.executable,
                        },
                        _ => anyhow::bail!(
                            "{}: give exactly one of content, base64 or delete",
                            c.path
                        ),
                    });
                }
                let spec = Spec {
                    branch: p.branch,
                    message: p.message,
                    changes,
                    base: p.base,
                    retries: 5,
                    via_gateway: p.batch,
                    ..Default::default()
                };
                let c = commit::commit(&self.ctx, &repo_url(&p.repo)?, &spec).await?;
                Ok(json!({"sha": c.sha, "parent": c.parent, "changed": c.changed}))
            }
            .await,
        )
    }

    #[tool(
        description = "Create a repository in your account, public or private. It has no branches until the first commit, which creates the branch it names."
    )]
    async fn create_repo(
        &self,
        Parameters(p): Parameters<Create>,
    ) -> Result<CallToolResult, McpError> {
        answer(async {
            let me = knotless::session::me(&self.ctx).await?;
            let url = RepoUrl::parse(&format!("knotless://{}/{}", me.did, p.name))?;
            let new = NewRepo {
                private: p.private,
                description: p.description,
                default_branch: None,
                ..Default::default()
            };
            manage::create(&self.ctx, &url, &new).await?;
            Ok(json!({"repo": format!("{}/{}", me.handle, p.name), "url": format!("knotless://{}/{}", me.handle, p.name)}))
        }
        .await)
    }

    #[tool(
        description = "Rename a repository you own, public or private. The old name keeps redirecting to it, and pulls, issues and forks follow it."
    )]
    async fn rename_repo(
        &self,
        Parameters(p): Parameters<Rename>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let to = manage::rename(&self.ctx, &repo_url(&p.repo)?, &p.name).await?;
                Ok(json!({"url": to.to_string()}))
            }
            .await,
        )
    }

    #[tool(
        description = "Offer a public repository you own to another account, or withdraw the offer. It moves, with its pulls, issues and forks, when that account accepts."
    )]
    async fn transfer_repo(
        &self,
        Parameters(p): Parameters<Transfer>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let url = repo_url(&p.repo)?;
                let to = match p.to.as_deref() {
                    Some(to) if to.contains('/') => Some(repo_url(to)?),
                    Some(to) => Some(repo_url(&format!("{to}/{}", url.name))?),
                    None => None,
                };
                manage::transfer(&self.ctx, &url, to.as_ref()).await?;
                Ok(json!({"offeredTo": to.map(|u| u.to_string())}))
            }
            .await,
        )
    }

    #[tool(
        description = "Take up a repository offered to your account: it is copied in and moves here, with its pulls, issues and forks."
    )]
    async fn accept_transfer(
        &self,
        Parameters(p): Parameters<Repo>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let to = forge::accept(&self.ctx, &repo_url(&p.repo)?).await?;
                Ok(json!({"url": to.to_string()}))
            }
            .await,
        )
    }

    #[tool(
        description = "Share a repository you own: let an account read it and open pulls and issues (private repositories), push to it and merge, or with revoke, take that back. Sharing for writing needs your writes delegated to a gateway first (delegate)."
    )]
    async fn share(&self, Parameters(p): Parameters<Share>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let url = repo_url(&p.repo)?;
                if p.revoke {
                    manage::unshare(&self.ctx, &url, &p.with).await?;
                } else {
                    manage::share(&self.ctx, &url, &p.with, p.write).await?;
                }
                let value = manage::members(&self.ctx, &url).await?;
                Ok(serde_json::to_value(value)?)
            }
            .await,
        )
    }

    #[tool(
        description = "Protect a branch of a repository you own, or with remove, stop: collaborators can then only move it forward and never delete it, and unless merges is set, put no merge commits on it. The gateway holds them to it. Answers the protected branches."
    )]
    async fn protect_branch(
        &self,
        Parameters(p): Parameters<Protect>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let rule = (!p.remove).then(|| knotless::records::Rule::new(&p.branch, p.merges));
                let url = repo_url(&p.repo)?;
                let rules = manage::protect(&self.ctx, &url, &p.branch, rule).await?;
                Ok(serde_json::to_value(rules)?)
            }
            .await,
        )
    }

    #[tool(
        description = "Fork a repository into your account, with all its branches and tags. A private repository forks into a private one that its owner can read, so they can take pulls from it."
    )]
    async fn fork(&self, Parameters(p): Parameters<Fork>) -> Result<CallToolResult, McpError> {
        answer(async {
            let url = forge::fork(&self.ctx, &repo_url(&p.repo)?, p.name.as_deref()).await?;
            let me = knotless::session::me(&self.ctx).await?;
            Ok(json!({"repo": format!("{}/{}", me.handle, url.name), "url": format!("knotless://{}/{}", me.handle, url.name)}))
        }
        .await)
    }

    #[tool(
        description = "Open a pull request: ask to merge head_branch of head_repo (this repository or a fork) into base."
    )]
    async fn open_pull(
        &self,
        Parameters(p): Parameters<OpenPull>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let uri = forge::open_pull(
                    &self.ctx,
                    &repo_url(&p.repo)?,
                    &p.base,
                    &repo_url(&p.head_repo)?,
                    &p.head_branch,
                    &p.title,
                    p.body.as_deref(),
                )
                .await?;
                Ok(json!({"uri": uri}))
            }
            .await,
        )
    }

    #[tool(description = "Open an issue on a repository.")]
    async fn open_issue(
        &self,
        Parameters(p): Parameters<OpenIssue>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let uri =
                    forge::open_issue(&self.ctx, &repo_url(&p.repo)?, &p.title, p.body.as_deref())
                        .await?;
                Ok(json!({"uri": uri}))
            }
            .await,
        )
    }

    #[tool(
        description = "Pull requests and issues on a repository, newest first, with their state and comment counts."
    )]
    async fn list_items(
        &self,
        Parameters(p): Parameters<Items>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let items: Vec<forge::Item> = forge::list(&self.ctx, &repo_url(&p.repo)?)
                    .await?
                    .into_iter()
                    .filter(|i| p.kind.as_ref().is_none_or(|k| *k == i.kind))
                    .filter(|i| p.state.as_ref().is_none_or(|s| *s == i.state))
                    .collect();
                Ok(serde_json::to_value(items)?)
            }
            .await,
        )
    }

    #[tool(
        description = "A pull request or issue with its comments and reviews, oldest first. For a pull, `compare` says what it brings in: the base and head commits, the commits the base lacks, and whether it merges (merged, fast-forward, clean or conflict, with the conflicting paths)."
    )]
    async fn show_item(
        &self,
        Parameters(p): Parameters<Subject>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let url = repo_url(&p.repo)?;
                let thread = forge::show(&self.ctx, &url, &p.uri).await?;
                let comments: Vec<Value> = thread
                    .comments
                    .iter()
                    .map(|c| {
                        json!({
                            "uri": c.uri,
                            "author": c.author,
                            "handle": thread.handles.get(&c.author),
                            "body": c.value["body"],
                            "verdict": c.value["verdict"],
                            "commit": c.value["commit"],
                            "createdAt": c.value["createdAt"],
                        })
                    })
                    .collect();
                let mut out = json!({"item": thread.item, "comments": comments});
                if thread.item.kind == "pull" {
                    out["compare"] = match forge::compare(&self.ctx, &url, &p.uri).await {
                        Ok(compared) => serde_json::to_value(compared)?,
                        Err(err) => json!({"error": format!("{err:#}")}),
                    };
                }
                Ok(out)
            }
            .await,
        )
    }

    #[tool(
        description = "The unified diff a pull request would apply: its head, in whichever repository it is, against where it left the branch it targets."
    )]
    async fn pull_diff(
        &self,
        Parameters(p): Parameters<Subject>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let diff = forge::pull_diff(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?;
                Ok(Value::String(diff))
            }
            .await,
        )
    }

    #[tool(
        description = "Rename a branch: the new one starts where the old one is, open pull requests that target it are pointed at the new name, and the old one is deleted. Not the default branch. The owner and collaborators may."
    )]
    async fn rename_branch(
        &self,
        Parameters(p): Parameters<RenameBranch>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let url = repo_url(&p.repo)?;
                let moved = forge::rename_branch(&self.ctx, &url, &p.from, &p.to).await?;
                Ok(json!({"branch": p.to, "retargeted": moved}))
            }
            .await,
        )
    }

    #[tool(
        description = "Comment on a pull request or issue. With a verdict of approve or changes, review a pull at its current head."
    )]
    async fn comment(&self, Parameters(p): Parameters<Remark>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let uri = forge::comment(
                    &self.ctx,
                    &repo_url(&p.repo)?,
                    &p.uri,
                    &p.body,
                    p.verdict.as_deref(),
                )
                .await?;
                Ok(json!({"uri": uri}))
            }
            .await,
        )
    }

    #[tool(
        description = "Close or reopen a pull request or issue. Its author, the owner and collaborators may."
    )]
    async fn set_state(
        &self,
        Parameters(p): Parameters<SetState>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                forge::set_state(&self.ctx, &repo_url(&p.repo)?, &p.uri, &p.state).await?;
                Ok(json!({"state": p.state}))
            }
            .await,
        )
    }

    #[tool(
        description = "Merge an open pull request: a fast-forward when possible, else a merge commit; into a branch protected as linear, one squashed commit. Fails on conflicts. The owner and collaborators may."
    )]
    async fn merge(&self, Parameters(p): Parameters<Subject>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let sha = forge::merge(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?;
                Ok(json!({"sha": sha}))
            }
            .await,
        )
    }

    #[tool(
        description = "Point an open pull request at other branches, as after a branch was renamed. Anyone who may close it can change its target branch; only its author its head branch."
    )]
    async fn retarget_pull(
        &self,
        Parameters(p): Parameters<Retarget>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let url = repo_url(&p.repo)?;
                forge::retarget(
                    &self.ctx,
                    &url,
                    &p.uri,
                    p.base.as_deref(),
                    p.head.as_deref(),
                )
                .await?;
                Ok(json!({"retargeted": p.uri}))
            }
            .await,
        )
    }

    #[tool(
        description = "As a repository's owner, hide a pull, issue or comment on it from everyone, with what was said about it, or show it again."
    )]
    async fn hide_item(&self, Parameters(p): Parameters<Hide>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let hidden = p.hidden.unwrap_or(true);
                forge::hide(&self.ctx, &repo_url(&p.repo)?, &p.uri, hidden).await?;
                Ok(json!({"uri": p.uri, "hidden": hidden}))
            }
            .await,
        )
    }

    #[tool(
        description = "Block an account, or unblock it: nothing it writes about your repositories, or about the one repository named, shows."
    )]
    async fn block_account(
        &self,
        Parameters(p): Parameters<Block>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let blocked = p.blocked.unwrap_or(true);
                let repo = p.repo.as_deref().map(repo_url).transpose()?;
                let did = moderation::block(&self.ctx, &p.account, repo.as_ref(), blocked).await?;
                Ok(json!({"did": did, "blocked": blocked}))
            }
            .await,
        )
    }

    #[tool(
        description = "Report an account, a repository, or a pull, issue or comment to the moderation service."
    )]
    async fn report(&self, Parameters(p): Parameters<Report>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let subject = target(&p.subject);
                moderation::report(&self.ctx, &subject, &p.reason, p.text.as_deref()).await
            }
            .await,
        )
    }

    #[tool(
        description = "As the appview's operator (KNOTLESS_ADMIN_PASSWORD), take an account, repository, pull, issue or comment down, or put it back."
    )]
    async fn takedown(
        &self,
        Parameters(p): Parameters<Takedown>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let subject = target(&p.subject);
                let applied = p.applied.unwrap_or(true);
                Admin::new(&self.ctx)?
                    .takedown(&self.ctx, &subject, applied)
                    .await
            }
            .await,
        )
    }

    #[tool(description = "As the appview's operator, whether something is taken down.")]
    async fn moderation_status(
        &self,
        Parameters(p): Parameters<Moderated>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let subject = target(&p.subject);
                Admin::new(&self.ctx)?.status(&self.ctx, &subject).await
            }
            .await,
        )
    }

    #[tool(description = "As the appview's operator, the reports it was sent, newest first.")]
    async fn list_reports(
        &self,
        Parameters(p): Parameters<Reports>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let reports = Admin::new(&self.ctx)?
                    .reports(p.limit.unwrap_or(50))
                    .await?;
                Ok(json!({"reports": reports}))
            }
            .await,
        )
    }

    #[tool(
        description = "Let a gateway write to your repositories for the collaborators you share them with. Opens the browser for you to approve it at your PDS, and waits until you have. Needed once before sharing for writing."
    )]
    async fn delegate(
        &self,
        Parameters(p): Parameters<Delegate>,
    ) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let gateway = match p.gateway {
                    Some(did) => did,
                    None => gateway::appview(&self.ctx)?,
                };
                let me = knotless::session::me(&self.ctx).await?;
                // stdout carries the protocol; the link to approve goes to stderr.
                gateway::delegate(&self.ctx, &me, &gateway, &mut std::io::stderr()).await?;
                Ok(json!({"delegated": gateway}))
            }
            .await,
        )
    }

    #[tool(
        description = "Who may read and push a repository. Only its owner can list a private one's readers."
    )]
    async fn members(&self, Parameters(p): Parameters<Repo>) -> Result<CallToolResult, McpError> {
        answer(
            async {
                let value = manage::members(&self.ctx, &repo_url(&p.repo)?).await?;
                Ok(serde_json::to_value(value)?)
            }
            .await,
        )
    }
}

#[tool_handler(
    name = "knotless",
    instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Read with list_tree, read_file, search, log and diff: no clone needed. Change files with commit: no checkout needed. Collaborate with fork, open_pull, open_issue, list_items, show_item, comment, set_state and merge. Moderate your repositories with hide_item and block_account, and report with report."
)]
impl ServerHandler for Server {}

/// Serves MCP over stdin and stdout until the client hangs up.
pub async fn serve_stdio(ctx: Ctx) -> anyhow::Result<()> {
    use rmcp::ServiceExt;
    let running = Server::new(ctx).serve(rmcp::transport::stdio()).await?;
    running.waiting().await?;
    Ok(())
}