johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! 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,
//! and pulls, issues, diffs and files are resources to read by URI too.
//! Everything runs as the account `knotless login` last logged in, or as
//! `KNOTLESS_IDENTIFIER`.
pub mod http;
mod resources;
use knotless::approvals;
use knotless::checks;
use knotless::commit::{self, Change, Spec};
use knotless::compact;
use knotless::env::Ctx;
use knotless::fix::{self, Words};
use knotless::forge;
use knotless::gateway;
use knotless::git::Git;
use knotless::landing;
use knotless::manage::{self, NewRepo};
use knotless::moderation::{self, Admin};
use knotless::org;
use knotless::profile;
use knotless::query::Query;
use knotless::read;
use knotless::records::{self, LineRef, RerunVar};
use knotless::release;
use knotless::remote::Remote;
use knotless::social;
use knotless::theme::{self, Choice};
use knotless::triage;
use knotless::url::RepoUrl;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{
CallToolResponse, CallToolResult, ContentBlock, Implementation, ListResourceTemplatesResult,
PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResponse, ServerCapabilities,
ServerConfig,
};
use rmcp::{ErrorData as McpError, ServerHandler, schemars, tool, tool_handler, tool_router};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Clone)]
pub struct Server {
ctx: Ctx,
/// Why no tool may run: this version is older than the appview asks for.
refusal: Option<String>,
/// That a newer version is out, said with the first tool's answer.
notice: Arc<Mutex<Option<String>>>,
/// Whether it offers only the tools in [`READS`].
read_only: bool,
}
/// The tools that only read, all a read-only server offers: an agent
/// given one, with whatever login, can change nothing.
pub const READS: &[&str] = &[
"status",
"repo_list",
"repo_refs",
"repo_storage",
"repo_tree",
"repo_read",
"repo_blame",
"repo_search",
"repo_log",
"repo_diff",
"repo_members",
"repo_stars",
"pull_template",
"issue_template",
"pull_list",
"issue_list",
"pull_show",
"issue_show",
"pull_diff",
"label_list",
"triager_list",
"branch_rules",
"queue_list",
"account_follows",
"account_followers",
"account_search",
"theme_list",
"theme_check",
"run_list",
"workflow_list",
"runner_list",
"runner_minutes",
"agent_list",
"release_list",
"release_read",
];
/// 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(),
}
}
/// How long, in bytes, a numbered diff `pull_diff` gives whole may be: an
/// MCP client cuts off a tool's answer much longer (Claude Code at 25,000
/// tokens), and an agent reads what it was given as all there is.
const WHOLE_DIFF: usize = 60_000;
/// One file's part of a diff: from its `diff --git` line to the next.
struct Section<'a> {
/// The path the diff names it by: the new one, for a file renamed.
path: &'a str,
text: &'a str,
added: usize,
removed: usize,
}
/// `diff`'s files, in its order. Anything before the first `diff --git`
/// line belongs to none.
fn sections(diff: &str) -> Vec<Section<'_>> {
let mut out: Vec<Section> = Vec::new();
let (mut start, mut at, mut hunks) = (0, 0, false);
for line in diff.split_inclusive('\n') {
if let Some(names) = line.strip_prefix("diff --git ") {
let names = names.trim_end_matches('\n');
let path = names.split_once(" b/").map_or(names, |(_, b)| b);
(start, hunks) = (at, false);
out.push(Section {
path,
text: "",
added: 0,
removed: 0,
});
}
at += line.len();
let Some(file) = out.last_mut() else {
continue;
};
file.text = &diff[start..at];
if line.starts_with("@@ ") {
hunks = true;
} else if hunks {
file.added += line.starts_with('+') as usize;
file.removed += line.starts_with('-') as usize;
}
}
out
}
/// What `pull_diff` answers of `diff`: the files `paths` names, numbered;
/// with none named, all of it, unless it is longer than `whole`, when it
/// lists the files to ask for by `paths` instead.
fn pull_diff_answer(diff: &str, paths: &[String], whole: usize) -> anyhow::Result<Value> {
let files = sections(diff);
if paths.is_empty() {
let all = numbered(diff);
if all.len() <= whole {
return Ok(Value::String(all));
}
let listed: Vec<Value> = files
.iter()
.map(|f| {
let lines = f.text.lines().count();
json!({"path": f.path, "added": f.added, "removed": f.removed, "lines": lines})
})
.collect();
let said = format!(
"The diff is {} lines in {} files, too long to give whole. Ask for it again with `paths`, a few files at a time, until every file is read.",
diff.lines().count(),
files.len()
);
return Ok(json!({"said": said, "files": listed}));
}
if let Some(p) = paths.iter().find(|p| files.iter().all(|f| f.path != *p)) {
anyhow::bail!(
"the diff changes no file {p}: `pull_diff` without `paths` lists those it does"
);
}
let picked: String = files
.iter()
.filter(|f| paths.iter().any(|p| p == f.path))
.map(|f| f.text)
.collect();
Ok(Value::String(numbered(&picked)))
}
/// `diff` with each line of its hunks numbered, as a line comment takes
/// it: its line in the old file, then in the new, each blank on the side
/// the line is not on, then ` | ` and the line as the diff has it. Lines
/// outside hunks (file headers, `@@`, `\ No newline`) are not numbered.
fn numbered(diff: &str) -> String {
let mut rows = Vec::new();
let (mut old, mut new, mut old_left, mut new_left) = (0, 0, 0, 0);
let mut widest = 0;
for line in diff.lines() {
if old_left + new_left == 0 || line.starts_with('\\') {
if let Some((o, ol, n, nl)) = hunk(line) {
(old, old_left, new, new_left) = (o, ol, n, nl);
widest = widest.max(o + ol).max(n + nl);
}
rows.push((None, None, line));
continue;
}
let (o, n) = match line.as_bytes().first() {
Some(b'-') => (Some(old), None),
Some(b'+') => (None, Some(new)),
_ => (Some(old), Some(new)),
};
if o.is_some() {
(old, old_left) = (old + 1, old_left.saturating_sub(1));
}
if n.is_some() {
(new, new_left) = (new + 1, new_left.saturating_sub(1));
}
rows.push((Some(o), Some(n), line));
}
let w = widest.to_string().len();
let cell = |n: Option<Option<u64>>| n.flatten().map_or(String::new(), |n| n.to_string());
let mut out = String::new();
for (o, n, line) in rows {
if o.is_none() {
out += &format!("{line}\n");
} else {
out += &format!("{:>w$} {:>w$} | {line}\n", cell(o), cell(n));
}
}
out
}
/// A hunk header's `(old start, old count, new start, new count)`.
fn hunk(line: &str) -> Option<(u64, u64, u64, u64)> {
let mut spans = line.strip_prefix("@@ -")?.split(' ');
let (o, n) = (spans.next()?, spans.next()?.strip_prefix('+')?);
let span = |s: &str| -> Option<(u64, u64)> {
let (start, count) = s.split_once(',').unwrap_or((s, "1"));
Some((start.parse().ok()?, count.parse().ok()?))
};
let ((o, ol), (n, nl)) = (span(o)?, span(n)?);
Some((o, ol, n, nl))
}
/// How much of a check's log a tool gives: its end, where a failure is.
const LOG_TAIL: usize = 8000;
/// The last [`LOG_TAIL`] bytes of `text`, from a character boundary.
fn tail(text: String) -> String {
let mut from = text.len().saturating_sub(LOG_TAIL);
while !text.is_char_boundary(from) {
from += 1;
}
text[from..].to_string()
}
/// The longest a tool waits for checks to finish.
const MOST_WAIT: u64 = 300;
/// The most pictures `checks` gives at once.
const MOST_PICTURES: usize = 12;
/// 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(fix::said(&err, Words::Tools))]),
})
}
/// How the user grants what the refusal for want of scope in `said` lacks,
/// said to the model: signing in again, when the login knotless acts with
/// is `behind` what knotless asks for now; else that the PDS did not grant
/// what it asked for.
fn grant(said: &str, behind: &dyn Fn() -> bool) -> Option<&'static str> {
if !knotless::oauth::out_of_scope(&said) {
return None;
}
Some(match (behind(), knotless::oauth::in_space(&said)) {
(true, _) => LOG_IN_AGAIN,
(false, true) => NOT_LISTED,
(false, false) => NOT_GRANTED,
})
}
/// How the user grants what their login lacks, said to the model.
const LOG_IN_AGAIN: &str = "The PDS refused this because the login knotless acts with is older than what knotless asks for now. Ask the user to sign in again to allow it: to reconnect knotless in their assistant's connector settings, or, for a knotless run on their machine, to run `knotless login <handle>` in a terminal.";
/// What is said of a refusal for want of scope the login asked for.
const NOT_GRANTED: &str = "The PDS did not grant this, though the login knotless acts with asks for it: signing in again asks for nothing more.";
/// What is said of a write in a space the login asked for, but its PDS
/// left out of the grant.
const NOT_LISTED: &str = "The PDS did not grant this write in the space, though the login knotless acts with asks for it: a PDS grants writes in a space as the lexicon knotless publishes for the space's type listed them when the user signed in, and that left this one out. Once knotless's lexicon lists it, the user signs in again to allow it: to reconnect knotless in their assistant's connector settings, or, for a knotless run on their machine, to run `knotless login <handle>` in a terminal.";
/// A comment's `line`, as its record has it, with what the record leaves
/// to be read said too: the side, `new` unless it is `old`, and the first
/// line, the last unless it spans more.
fn lines(line: &Value) -> Value {
let mut line = line.clone();
if let Some(at) = line.as_object_mut() {
let last = at.get("line").cloned().unwrap_or_default();
at.entry("side").or_insert_with(|| "new".into());
at.entry("startLine").or_insert(last);
}
line
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Handle {
/// A handle or DID.
pub handle: 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>,
/// The first line to read, counted from 1: with `end`, a part of a long
/// file rather than all of it.
pub start: Option<usize>,
/// The last line to read; the file's last by default.
pub end: Option<usize>,
}
#[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>,
/// Search only here: a directory, a file, or a glob such as `*.rs`.
pub path: Option<String>,
/// Lines to show either side of each match, to write an edit from
/// without reading the file.
pub context: Option<usize>,
/// 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 Checks {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// Only checks of pushes to this ref, and runs asked for there, such
/// as `refs/heads/main`, or a branch or tag named bare, as `main`: a
/// pull's runs are found by `pull`, a batch's by `queue`.
#[serde(rename = "ref")]
pub refname: Option<String>,
/// Only checks of this commit: its hex object ID, or at least its
/// first 4 digits.
pub commit: Option<String>,
/// Only the runs of this pull's head, by the pull's AT-URI.
pub pull: Option<String>,
/// Only the runs of this workflow, by name.
pub workflow: Option<String>,
/// Only the runs of batches of this branch's merge queue, by the
/// branch's name, such as `main`.
pub queue: Option<String>,
/// With the end of each check's log, to see why one failed.
pub log: Option<bool>,
/// With the pictures the checks made, as images, each after a line
/// naming its workflow and what it shows: the pages a change moved,
/// before and after, say. At most 12.
pub images: Option<bool>,
/// Seconds to wait, at most 300, for a check to show and for none
/// still to be running, rather than asking again.
pub wait: Option<u64>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct RepoHandle {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// The account, by handle or DID.
pub handle: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct SecretSet {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// The secret's name, a shell variable's, as a workflow's `secrets`
/// lists it.
pub name: String,
/// The secret's value.
pub value: String,
/// The refs whose pushes it reaches, as a trigger's `refs` match
/// (`refs/heads/main`, `refs/tags/v*`); every push if none.
pub refs: Option<Vec<String>>,
/// The runners, by handle or DID, to give it to, of those the
/// repository names; every one if none. Another's copy of the same name
/// is taken back.
pub runners: Option<Vec<String>>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct RepoName {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// The name.
pub name: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Rerun {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// The ref to run again, in full, such as `refs/heads/main`.
#[serde(rename = "ref")]
pub refname: Option<String>,
/// Or the pull whose head to run again, by its AT-URI.
pub pull: Option<String>,
/// Only this workflow, by name: of the pull's, or at a ref, one that
/// runs on a push to it, or that runs only when asked (`on: {asked: {}}`).
pub workflow: Option<String>,
/// Variables its steps see, by name, with the workflow at a ref.
#[serde(default)]
pub env: BTreeMap<String, String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct RunCancel {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// The ref whose runs to cancel, such as `refs/heads/main`.
#[serde(rename = "ref")]
pub refname: Option<String>,
/// Or the pull whose runs to cancel, by its AT-URI.
pub pull: Option<String>,
/// Only this workflow's runs, by name.
pub workflow: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ReleaseFile {
/// Its name, as it is downloaded: letters, digits, `.`, `_`, `+` and
/// `-`, not first a `.`.
pub name: String,
/// Its content, as text.
pub content: Option<String>,
/// Its content, base64, for a binary file.
pub base64: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Publish {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// The package's name, as npm's rules have it, such as `knotless` or
/// `@scope/tool`.
pub package: String,
/// A Semantic Versioning 2.0 version, such as 1.4.0 or 2.0.0-rc.1.
pub version: String,
pub files: Vec<ReleaseFile>,
/// The commit the files were built from, by its hex ID.
pub commit: Option<String>,
/// The at:// URI of the check that built them.
pub check: Option<String>,
/// What changed since the version before, in Markdown.
pub notes: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Releases {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// Only this package's.
pub package: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Yank {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
pub package: String,
/// The version to withdraw.
pub version: String,
/// Take the yank back.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ReadRelease {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
pub package: String,
/// The version. Defaults to the latest: the highest without a
/// pre-release part.
pub version: Option<String>,
/// The file's name.
pub file: String,
}
/// The largest release file `release_read` gives: past this, an answer is
/// no use to a model, and `knotless release download` fetches it.
const MOST_RELEASE_READ: u64 = 1 << 20;
#[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>,
/// Replacements in the file as the branch holds it, made in order:
/// for a change to part of a text file, rather than all its content.
/// The file keeps its mode.
pub edits: Option<Vec<TextEdit>>,
/// Delete the file rather than write it.
#[serde(default)]
pub delete: bool,
#[serde(default)]
pub executable: bool,
}
#[derive(Deserialize, Serialize, schemars::JsonSchema)]
pub struct TextEdit {
/// Text the file holds exactly once: take in enough lines around what
/// changes to make it so.
pub old: String,
/// What takes its place.
pub new: String,
}
#[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 name: 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 name: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct NewTag {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// Without refs/tags/, such as `v1.0.0`.
pub name: String,
/// A commit, branch or tag to put it at; the default branch when left out.
pub from: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OldTag {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// Without refs/tags/.
pub name: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Create {
/// The new repository's name, in your account.
pub name: String,
/// The accounts, by handle or DID, whose checks of it are shown, in
/// place of those the appview offers; empty names none.
pub runners: Option<Vec<String>>,
/// Readable only by the accounts it is shared with.
#[serde(default)]
pub private: bool,
pub description: Option<String>,
/// Links its pages show, in order, each an http or https address: its
/// website first. At most five.
pub links: Option<Vec<ProfileLink>>,
/// The branch its HEAD names: main if not given.
pub default_branch: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct DeleteRepo {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// The repository's name again, to say it is meant: deleting it
/// removes all of its history, its pulls and issues, for good.
pub confirm: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct DeleteAccountData {
/// Your handle, to say it is meant: deleting removes every knotless
/// record from your account, for good. Left out, the tool only says
/// what there is.
pub confirm: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct SearchOwners {
/// Text the handle holds, any case.
pub text: String,
/// At most this many, up to 50. Defaults to 10.
pub limit: Option<u32>,
}
#[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 Describe {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// What the repository is, shown under its name; empty drops it.
pub description: Option<String>,
/// Links its pages show, in order, each an http or https address: its
/// website first. This list replaces the one there was; empty drops
/// them. At most five.
pub links: Option<Vec<ProfileLink>>,
/// What it is about, as rust or atproto: lowercase letters, digits and
/// dashes. This list replaces the one there was; empty drops them. At
/// most twenty.
pub topics: Option<Vec<String>>,
/// The branch its HEAD names: one of its branches, once it has any.
pub default_branch: Option<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`. Not with undo.
pub to: Option<String>,
/// Withdraw the offer instead.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Archive {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// True to unarchive it. Defaults to false.
pub undo: Option<bool>,
}
#[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,
/// Show it again.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Block {
/// The handle or DID to block.
pub handle: String,
/// Only from this repository (`handle/name`); from all of yours when
/// left out.
pub repo: Option<String>,
/// Unblock instead.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Blocks {
/// Whose blocks, by handle or DID: yours when left out.
pub handle: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Email {
/// The address, as git authors commits with it.
pub address: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct NotificationSettings {
/// Reasons to hear of again, as `star`, `follow`, `pull`, `issue`,
/// `comment` or `mention`.
pub on: Option<Vec<String>>,
/// Reasons to stop hearing of.
pub off: Option<Vec<String>>,
/// Hear only of what accounts you follow did.
#[serde(default)]
pub followed_only: bool,
/// Hear of what anyone did again.
#[serde(default)]
pub anyone: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Watch {
/// A repository, as `handle/name`, to watch or ignore.
pub repo: String,
/// `all` (the default), `releases` or `ignore`; `participating`
/// forgets the choice.
pub level: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Uri {
/// A pull's or issue's at:// URI.
pub uri: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Star {
/// The repository, as `handle/name`: a public one, since a star is.
pub repo: String,
/// Take your star back.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct React {
/// The repository, as `handle/name`: a public one, since a reaction is.
pub repo: String,
/// The pull's, issue's or comment's at:// URI.
pub uri: String,
/// `+1`, `-1`, `laugh`, `hooray`, `confused`, `heart`, `rocket` or `eyes`.
pub content: String,
/// Take your reaction back.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Stars {
/// The handle or DID whose stars to list; yours when left out.
pub handle: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Notifications {
/// How many, the newest first: 50 by default, at most 100.
pub limit: Option<u32>,
/// Leave them unread. They are marked seen otherwise, unless of one kind.
#[serde(default)]
pub peek: bool,
/// Only those of this kind: mentions, reviews (asked of you, and those of your pulls), comments (that do not review), pulls-and-issues, checks (failed, and ejections from the merge queue) or stars-and-follows. A listing of one kind marks none seen.
pub kind: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Follow {
/// The handle or DID to follow.
pub handle: String,
/// Unfollow instead.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Follows {
/// The handle or DID whose follows, or followers, to list; yours when
/// left out.
pub handle: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Themes {
/// The handle or DID whose themes to list; everyone's when left out.
pub handle: Option<String>,
/// Only those whose name starts with this, in any case, by name.
pub find: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ThemeCheck {
/// A palette, as `dev.knotless.theme#palette`: `colorScheme` (`light`
/// or `dark`), `background`, `text`, `primary` and `accent`, and any
/// of `panel`, `soft`, `border`, `textMuted`, `textFaint`,
/// `onPrimary`, `success`, `danger`, `done`, `attention`, `neutral`
/// and `syntax` (`comments`, `strings`, `keywords`, `numbers`,
/// `functions`, `types`, `tags`), each `#rrggbb`.
pub palette: Value,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ThemePublish {
/// The theme's name.
pub name: String,
/// Its palette, as `theme_check` takes it.
pub palette: Value,
/// Draw your pages in it too.
#[serde(default, rename = "use")]
pub using: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ThemeUse {
/// What to draw your pages in: `system`, `light`, `dark`, `latte`,
/// `mocha`, `dracula`, `pckt`, `plyr`, `blacksky`, a published
/// theme's `at://` URI, or `palette` for the one given.
pub theme: String,
/// A palette of your own, unpublished, with `palette` as the theme.
pub palette: Option<Value>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ThemeDelete {
/// The `at://` URI of a theme of yours.
pub uri: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ProfileLink {
pub url: String,
/// What to call it; its host, if left out.
pub title: Option<String>,
}
/// `links` as records hold them.
fn recorded(links: Vec<ProfileLink>) -> Vec<records::ProfileLink> {
let link = |l: ProfileLink| records::ProfileLink {
url: l.url,
title: l.title,
};
links.into_iter().map(link).collect()
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct SetProfile {
/// The name shown over your handle.
pub name: Option<String>,
pub bio: Option<String>,
pub pronouns: Option<String>,
/// Links to show, in order, each an http or https address; this list
/// replaces the one there was. Empty shows your Bluesky profile's.
pub links: Option<Vec<ProfileLink>>,
/// Your picture, a PNG, JPEG, WebP or GIF, as base64: one over 1 MB is
/// made smaller.
pub avatar: Option<String>,
/// Whether the account is a bot, such as a runner, a triager or a
/// reviewer: the feed leaves out what it says and pushes.
pub bot: 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,
/// Put it back instead.
#[serde(default)]
pub undo: 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 Stats {
/// How many days back, today the last. 14 by default.
pub days: Option<u32>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Logs {
/// All of one request's lines, by the trace ID its answer's
/// `x-trace-id` and each of its lines carry.
pub trace: Option<String>,
/// Only lines with this message: `request`, `outgoing`, `sql`, `mcp
/// tool`, `mcp tool failed`, `mcp resource`, `mcp resource failed`,
/// `git`.
pub message: Option<String>,
/// Only lines at this level or above: `error`, `warn`, `info`, `debug`.
pub level: Option<String>,
/// Only lines that took this many milliseconds or more.
pub min_ms: Option<u64>,
/// How many, the last of them: 100 by default, at most 500.
pub limit: Option<u32>,
}
#[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,
/// Let pulls land on it only through the merge queue: a merge then
/// queues them instead, unless the owner forces it.
#[serde(default)]
pub queue: bool,
/// How many approvals a pull needs to merge into it, 0 to 10, from the
/// owner and collaborators other than its author.
#[serde(default)]
pub reviews: u32,
/// A pull needs, for each path it changes that the CODEOWNERS file on
/// the branch gives owners, an approval from one of them.
#[serde(default)]
pub code_owners: bool,
/// Only approvals of the pull's head as it is now count.
#[serde(default)]
pub dismiss_stale: bool,
/// Accounts, by handle or DID, such as review agents, whose review of
/// a pull's head it waits for, though they may not write: one asking
/// for changes blocks it, and their approvals count toward nothing
/// else.
#[serde(default)]
pub gates: Vec<String>,
/// Stop protecting it instead.
#[serde(default)]
pub undo: 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 handle: 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 undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OrgAdd {
/// The handle or DID to invite to the organization, or whose role to
/// change: a member once they join it. A member's new role keeps how
/// their membership shows and when they joined.
pub handle: String,
/// As an admin, who also invites and removes members and makes
/// repositories as the organization.
#[serde(default)]
pub admin: bool,
/// The organization to invite to, by handle or DID; yours when left
/// out. To another, it goes through its gateway, as one of its admins.
pub org: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OrgRemove {
/// The handle or DID to take out of the organization, or whose
/// invitation to withdraw.
pub handle: String,
/// The organization to change, by handle or DID; yours when left out.
pub org: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OrgJoin {
/// The organization to accept an invitation to, by handle or DID, or to
/// change how your membership shows.
pub org: String,
/// Anyone sees you are a member, and you merge, close and label on all
/// its repositories.
#[serde(default)]
pub public: bool,
/// Only its members see it, and on its public repositories you push and
/// open pulls only.
#[serde(default)]
pub hidden: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OrgLeave {
/// The organization to leave, by handle or DID.
pub org: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OrgMembers {
/// The organization to list, by handle or DID; yours when left out.
pub org: Option<String>,
/// Answer the roster as someone outside the organization sees it: its
/// public members only, with no invitations or hidden members.
#[serde(default)]
pub public: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OrgList {
/// Whose memberships, by handle or DID; yours when left out.
pub handle: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OrgDelete {
/// To say it is meant: this takes every member out of your organization
/// and withdraws its invitations, so your account is no organization.
/// Without it, the tool only says what it would do.
#[serde(default)]
pub yes: 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 PullTemplate {
pub repo: String,
/// The branch the pull would merge into. Defaults to the default branch.
pub base: 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,
/// What the pull changes and why, in Markdown, following the
/// repository's template if it has one.
pub body: Option<String>,
/// Pictures to show after the body, such as screenshots.
pub images: Option<Vec<Picture>>,
/// Open it as a draft: not ready to merge or queue until you mark it
/// ready with pull_edit.
#[serde(default)]
pub draft: bool,
/// Accounts to ask to review it, by handle or DID.
pub reviewers: Option<Vec<String>>,
}
/// A picture to show in what you write, uploaded to your account and
/// attached to it.
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Picture {
/// The picture, a PNG, JPEG, WebP or GIF, as base64: one over 1 MB is
/// made smaller.
pub base64: String,
/// What it shows, for whoever cannot see it.
pub alt: String,
}
/// `pictures`, decoded, as [`forge::pictured`] takes them.
fn decoded(pictures: Option<Vec<Picture>>) -> anyhow::Result<Vec<(String, Vec<u8>)>> {
use base64::Engine;
let decode = |p: Picture| {
let bytes = base64::engine::general_purpose::STANDARD.decode(p.base64);
anyhow::Ok((p.alt, bytes?))
};
pictures
.unwrap_or_default()
.into_iter()
.map(decode)
.collect()
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OpenIssue {
pub repo: String,
pub title: String,
pub body: Option<String>,
/// Pictures to show after the body, such as screenshots.
pub images: Option<Vec<Picture>>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Items {
pub repo: String,
/// A filter, as GitHub's search reads one: `label:<name>`,
/// `-label:<name>`, `no:label`, `author:<handle or did>`,
/// `state:open|closed|merged`, `is:draft`, `-is:draft`,
/// `review-requested:<handle, did or @me>`,
/// `assignee:<handle, did or @me>`, `no:assignee`, and words the title
/// or body holds. Double quotes keep a value together.
pub query: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct MergePull {
pub repo: String,
/// The pull's at:// URI.
pub uri: String,
/// Keep its head branch, which otherwise goes with it.
#[serde(default)]
pub keep_branch: bool,
/// Seconds to wait, at most 300, for its checks still running or not
/// yet run to pass.
pub wait: Option<u64>,
/// As the owner, merge whatever its checks say.
#[serde(default)]
pub force: bool,
/// Put it in its branch's merge queue instead, and return at once:
/// a runner lands it with the batch it is in once that passes. Refused
/// unless the branch is protected with `queue`.
#[serde(default)]
pub queue: bool,
/// Say whether it would merge now, and why not, and what you may do
/// to it, without merging.
#[serde(default)]
pub check: bool,
/// Set it to merge by itself once its checks pass, on a branch with no
/// merge queue, and return at once: a runner merges it then.
#[serde(default)]
pub auto: bool,
/// With auto, cancel that instead.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct RevertPull {
pub repo: String,
/// The merged pull's at:// URI.
pub uri: String,
/// Why, said in the revert's body.
pub reason: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Workflows {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// The branch or tag, or the ref in full; the default branch if none.
#[serde(rename = "ref")]
pub refname: 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 PullDiff {
pub repo: String,
/// The pull's at:// URI.
pub uri: String,
/// Only these files' parts of the diff, by the paths it names them by
/// (a renamed file's new one). Without them, a diff too long to give
/// whole lists its files instead, to ask for so.
#[serde(default)]
pub paths: Vec<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 name: String,
/// Its new name.
pub new_name: 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; or a comment's, to answer it in
/// its thread.
pub uri: String,
/// What it says; may be empty when it sends only `lines`.
pub body: String,
/// Review someone else's pull at its current head, approving it.
#[serde(default)]
pub approve: bool,
/// Review someone else's pull at its current head, asking for changes.
#[serde(default)]
pub changes: bool,
/// Pictures to show after the body, such as screenshots.
pub images: Option<Vec<Picture>>,
/// Comments on lines of the pull's diff, sent with it at once.
pub lines: Option<Vec<LineNote>>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct LineNote {
/// The file's path, on the side commented on.
pub path: String,
/// The last line commented on, counting from 1, in the file as it is
/// on that side.
pub line: u32,
/// The first, for more than one line.
pub start_line: Option<u32>,
/// `new`, the pull's side (the default), or `old`, the side it leaves:
/// for lines it removes.
pub side: Option<String>,
pub body: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct EditPull {
pub repo: String,
/// The at:// URI of your pull.
pub uri: String,
/// The new title.
pub title: Option<String>,
/// The new body, in Markdown. Empty takes the body away.
pub body: Option<String>,
/// Pictures to show after the new body, which they need. The pictures
/// it had stay while the new body still shows them.
pub images: Option<Vec<Picture>>,
/// Make it a draft.
#[serde(default)]
pub draft: bool,
/// Mark a draft ready to merge.
#[serde(default)]
pub ready: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct EditIssue {
pub repo: String,
/// The at:// URI of your issue.
pub uri: String,
/// The new title.
pub title: Option<String>,
/// The new body, in Markdown. Empty takes the body away.
pub body: Option<String>,
/// Pictures to show after the new body, which they need. The pictures
/// it had stay while the new body still shows them.
pub images: Option<Vec<Picture>>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct EditComment {
pub repo: String,
/// The at:// URI of your comment.
pub uri: String,
/// The new body, in Markdown.
pub body: String,
/// Pictures to show after the new body.
pub images: Option<Vec<Picture>>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Unsay {
pub repo: String,
/// The at:// URI of your comment or review.
pub uri: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct DefineLabel {
pub repo: String,
/// Up to its last `/`, if it has one, its scope, as kind/bug's is kind.
pub name: String,
/// What it means, for those who put it on.
pub description: Option<String>,
/// A hex color, as #d73a4a.
pub color: Option<String>,
/// Of its scope's exclusive labels, as priority/high and priority/low,
/// only one stands at a time: setting one takes the others off.
#[serde(default)]
pub exclusive: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct TriagerAdd {
pub repo: String,
/// The account, by handle or DID, to name as a triager of `scopes`, in
/// place of what it was.
pub handle: String,
/// The scopes it may label, as kind for kind/bug.
pub scopes: Vec<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct LabelItem {
pub repo: String,
/// The pull's or issue's at:// URI.
pub uri: String,
/// The labels, by name, from those the repository defines.
pub labels: Vec<String>,
/// Take them off instead.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct AssignItem {
pub repo: String,
/// The pull's or issue's at:// URI.
pub uri: String,
/// The accounts, by handle or DID.
pub handles: Vec<String>,
/// Take them off instead, or stop asking.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct DismissReview {
pub repo: String,
/// The pull's at:// URI.
pub uri: String,
/// The reviewer whose standing review to dismiss, by handle or DID; or
/// the at:// URI of their review, whose author is taken.
pub reviewer: String,
/// Why, as the comment's Markdown body.
pub reason: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct ResolveComment {
pub repo: String,
/// The at:// URI of a comment that starts a thread on a pull's lines.
pub uri: String,
/// Open the thread again.
#[serde(default)]
pub undo: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct StatusOf {
/// The server's URL; the appview's when left out.
pub server: Option<String>,
/// Ask after this repository's mirror too, as `handle/name`.
pub repo: Option<String>,
}
impl Server {
pub fn new(ctx: Ctx) -> Server {
Server {
ctx,
refusal: None,
notice: Arc::default(),
read_only: false,
}
}
/// This server, offering only the tools in [`READS`]: the rest are
/// neither listed nor run.
pub fn read_only(self) -> Server {
Server {
read_only: true,
..self
}
}
/// What a tool or resource says of `err`: with, for a PDS refusing a
/// call for want of scope, how the user grants it, whichever way this
/// server was reached.
fn failed(&self, err: &anyhow::Error) -> String {
let said = fix::said(err, Words::Tools);
match self.grant(&said) {
Some(how) => format!("{said}\n\n{how}"),
None => said,
}
}
/// [`grant`], as the login this server acts with stands.
fn grant(&self, said: &str) -> Option<&'static str> {
grant(said, &|| knotless::oauth::login_behind(&self.ctx).is_some())
}
/// `done`, a tool's answer, saying as [`Server::failed`] does how the
/// user grants what a failure for want of scope lacks.
fn advise(&self, done: &mut CallToolResult) {
if done.is_error != Some(true) {
return;
}
let said = done.content.iter().filter_map(|c| c.as_text());
let said = said.map(|t| t.text.as_str()).collect::<Vec<_>>().join("\n");
if let Some(how) = self.grant(&said) {
done.content = vec![ContentBlock::text(format!("{said}\n\n{how}"))];
}
}
/// The tools it offers.
fn router(&self) -> rmcp::handler::server::router::tool::ToolRouter<Server> {
let mut router = Self::tool_router();
if self.read_only {
let all = router.list_all().into_iter().map(|t| t.name.to_string());
for name in all
.filter(|n| !READS.contains(&n.as_str()))
.collect::<Vec<_>>()
{
router.disable_route(name);
}
}
router
}
/// This server, refusing every tool when `known` asks for a newer
/// version (unless `KNOTLESS_IGNORE_MINIMUM`), and saying once when a
/// newer one is out.
pub fn knowing(self, known: &knotless::update::Known) -> Server {
let refusal = known.refusal().filter(|_| !self.ctx.env.ignore_minimum);
Server {
refusal,
notice: Arc::new(Mutex::new(known.notice())),
..self
}
}
/// The roster of `of`, yours if none, with what `changed`.
/// The roster after an invitation, join or removal, and what it changed.
async fn changed<T: serde::Serialize>(
&self,
of: Option<String>,
done: anyhow::Result<T>,
) -> anyhow::Result<Value> {
let changed = json!(done?);
self.roster_after(of, false, changed).await
}
async fn roster_after(
&self,
of: Option<String>,
public: bool,
changed: Value,
) -> anyhow::Result<Value> {
let of = match of {
Some(of) => of,
None => knotless::session::me(&self.ctx).await?.did,
};
let roster = org::roster(&self.ctx, &of, public).await?;
let mut answer = serde_json::to_value(roster)?;
answer["changed"] = changed;
Ok(answer)
}
/// A synced mirror of `repo`, to read from.
async fn mirror(&self, repo: &str) -> anyhow::Result<Git> {
knotless::mirror::synced(&self.ctx, &repo_url(repo)?).await
}
/// `repo`'s protected branches as answered: each whose rule asks for
/// code owners with `warnings`, of what CODEOWNERS on it gives the
/// repository's owner alone ([`approvals::owned_alone`]).
async fn rules(&self, repo: &str, rules: Vec<records::Rule>) -> anyhow::Result<Value> {
let mut answered = serde_json::to_value(&rules)?;
let asks = |r: &records::Rule| r.reviews.as_ref().is_some_and(|r| r.code_owners);
if !rules.iter().any(asks) {
return Ok(answered);
}
let warned = self.owned_alone(repo, &rules).await.unwrap_or_default();
let each = answered.as_array_mut().into_iter().flatten().zip(warned);
for (rule, warnings) in each.filter(|(_, w)| !w.is_empty()) {
rule["warnings"] = warnings.into();
}
Ok(answered)
}
/// For each of `rules` that asks for code owners, what
/// [`approvals::owned_alone`] warns of on its branch of `repo`.
async fn owned_alone(
&self,
repo: &str,
rules: &[records::Rule],
) -> anyhow::Result<Vec<Vec<String>>> {
let url = repo_url(repo)?;
let owner = knotless::identity::resolve(&self.ctx, &url.authority).await?;
let git = self.mirror(repo).await?;
let mut warned = Vec::new();
for rule in rules {
let asks = rule.reviews.as_ref().is_some_and(|r| r.code_owners);
let rev = format!("refs/heads/{}", rule.branch);
let said = approvals::owned_alone(&self.ctx, &git, &owner.did, &rev);
warned.push(if asks { said.await? } else { Vec::new() });
}
Ok(warned)
}
}
#[tool_router]
impl Server {
#[tool(
description = "Whether a knotless server (the appview) is well: its feeds, index and, with repo, a mirror of that repository, read from its /_status. Answers ok, and each part."
)]
async fn status(
&self,
Parameters(p): Parameters<StatusOf>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let base = match p.server {
Some(base) => base,
None => (self.ctx.env.appview.clone()).ok_or_else(|| {
anyhow::anyhow!("give a server: KNOTLESS_APPVIEW is set empty")
})?,
};
let status = knotless::status::fetch(&self.ctx, &base, p.repo.as_deref()).await?;
Ok(serde_json::to_value(status)?)
}
.await,
)
}
#[tool(
description = "List an account's repositories: its public ones, and your own private ones too when the account is yours."
)]
async fn repo_list(
&self,
Parameters(p): Parameters<Handle>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let repos = manage::list(&self.ctx, &p.handle).await?;
Ok(serde_json::to_value(repos)?)
}
.await,
)
}
#[tool(description = "A repository's branches and tags, with the commit each points at.")]
async fn repo_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 = "How much a repository takes up on its owner's PDS: its packs, the objects in them, their bytes, and how many of those bytes are in blobs rather than inline in records. Read from the pack records alone, with no download. Objects pushed more than once, or that no ref reaches any more, count until compaction folds the packs into one."
)]
async fn repo_storage(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let value = knotless::storage::storage(&self.ctx, &repo_url(&p.repo)?).await?;
Ok(serde_json::to_value(value)?)
}
.await,
)
}
#[tool(description = "List a directory: names, kinds (blob or tree), modes and sizes.")]
async fn repo_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. With `start` (and `end`), only those lines of a text file, from 1, as {start, end, lines (the file's count), text}: for a long file, search with `context` first, then read the part you need."
)]
async fn repo_read(&self, Parameters(p): Parameters<File>) -> Result<CallToolResult, McpError> {
answer(self.file(p).await)
}
/// A file, as `repo_read` answers it, and its resource too.
async fn file(&self, p: File) -> anyhow::Result<Value> {
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) if p.start.is_some() || p.end.is_some() => {
serde_json::to_value(read::lines(&text, p.start.unwrap_or(1), p.end)?)?
}
Ok(text) => Value::String(text),
Err(err) => {
use base64::Engine;
json!({"base64": base64::engine::general_purpose::STANDARD.encode(err.into_bytes())})
}
})
}
#[tool(
description = "Who last changed each line of a file, as git blame finds it: runs of lines, each with its first line (from 1), how many, the commit, its author (name, email, date) and summary, and `previous`, the commit's parent and the file's path there, to blame again before it. With `start` (and `end`), only those lines."
)]
async fn repo_blame(
&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 range = p.start.map(|start| (start, p.end.unwrap_or(start)));
let value = read::blame(&git, &rev, &read::path(Some(&p.path)), range).await?;
Ok(serde_json::to_value(value)?)
}
.await,
)
}
#[tool(
description = "Search the files at a revision, or those under `path`, for a fixed string, case-insensitively. Returns path, line number and line; with `context`, runs of lines around the matches instead, each {path, start, end, matches (their line numbers), text}, which is enough to write a commit's edits from without reading the file."
)]
async fn repo_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 path = read::path(p.path.as_deref());
let limit = p.limit.unwrap_or(100);
Ok(match p.context.unwrap_or(0) {
0 => {
let hits = read::grep(&git, &rev, &p.pattern, &path, limit).await?;
serde_json::to_value(hits)?
}
n => {
let runs = read::snippets(&git, &rev, &p.pattern, &path, n, limit).await?;
serde_json::to_value(runs)?
}
})
}
.await,
)
}
#[tool(description = "Commit history, newest first, optionally only commits touching a path.")]
async fn repo_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); when it changed nothing, says there are no differences."
)]
async fn repo_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))?);
let diff = read::diff(&git, &base, &head).await?;
Ok(Value::String(match diff.is_empty() {
true => format!("No differences: {head} changes nothing since {base}."),
false => diff,
}))
}
.await,
)
}
#[tool(
description = "Create a branch at a commit or another branch, or at the default branch. The owner and collaborators may."
)]
async fn branch_create(
&self,
Parameters(p): Parameters<NewBranch>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let sha = forge::branch(&self.ctx, &url, &p.name, p.from.as_deref(), false).await?;
Ok(json!({"branch": p.name, "sha": sha}))
}
.await,
)
}
#[tool(
description = "Delete a branch, such as one already merged. Not the default branch. The owner and collaborators may."
)]
async fn branch_delete(
&self,
Parameters(p): Parameters<OldBranch>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
forge::branch(&self.ctx, &url, &p.name, None, true).await?;
Ok(json!({"deleted": p.name}))
}
.await,
)
}
#[tool(
description = "Tag a commit, such as a release's: at a commit, branch or tag, or at the default branch. The owner and collaborators may."
)]
async fn tag_create(
&self,
Parameters(p): Parameters<NewTag>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let sha = forge::tag(&self.ctx, &url, &p.name, p.from.as_deref(), false).await?;
Ok(json!({"tag": p.name, "sha": sha}))
}
.await,
)
}
#[tool(description = "Delete a tag. The owner and collaborators may.")]
async fn tag_delete(
&self,
Parameters(p): Parameters<OldTag>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
forge::tag(&self.ctx, &url, &p.name, None, true).await?;
Ok(json!({"deleted": p.name}))
}
.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. Each change gives a file's content, or edits to make in it (each `old`, found exactly once, replaced by `new`), or deletes it: for a change to part of a file, edits, which need only the lines that change, rather than all of it. If the branch moved meanwhile, the changes are replayed onto its new tip, edits made in the file as it is there, unless `base` says what they were made against and a path they touch changed since. The commit is authored by you."
)]
async fn repo_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, c.edits) {
(true, None, None, None) => Change::Delete { path: c.path },
(false, None, None, Some(edits)) => Change::Edit {
path: c.path,
edits: edits
.into_iter()
.map(|e| commit::Edit {
old: e.old,
new: e.new,
})
.collect(),
},
(false, Some(text), None, None) => Change::Put {
path: c.path,
data: text.into_bytes(),
executable: c.executable,
},
(false, None, Some(b64), None) => Change::Put {
path: c.path,
data: base64::engine::general_purpose::STANDARD.decode(b64)?,
executable: c.executable,
},
_ => anyhow::bail!(
"{}: give exactly one of content, base64, edits 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. It names the runners the appview offers for new repositories (see runner_list), returned as runners; a private one's are members of it, so they can read it to run its checks, and warning, null otherwise, says if they could not be told of it. When your account is an organization, the members who may push to it are returned as members, by DID: on a public one, those anyone may see."
)]
async fn repo_create(
&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 {
runners: p.runners,
private: p.private,
description: p.description,
links: recorded(p.links.unwrap_or_default()),
default_branch: p.default_branch,
..Default::default()
};
let made = manage::create(&self.ctx, &url, &new).await?;
Ok(json!({"repo": format!("{}/{}", me.handle, p.name), "url": format!("knotless://{}/{}", me.handle, p.name), "runners": made.runners, "members": made.members, "warning": made.uninvited}))
}
.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 repo_rename(
&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 = "Change what a repository you own says it is, its description (empty drops it), the links its pages show, its website first (an empty list drops them), its topics (an empty list drops them), or the branch its HEAD names, which must be one of its branches once it has any. Answers all four as they now stand."
)]
async fn repo_edit(
&self,
Parameters(p): Parameters<Describe>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let change = manage::Description {
description: p.description.as_deref(),
links: p.links.map(recorded),
topics: p.topics,
default_branch: p.default_branch.as_deref(),
};
let now = manage::describe(&self.ctx, &url, change).await?;
let (description, links) = (now.description, now.links);
let (topics, branch) = (now.topics, now.default_branch);
Ok(json!({"description": description, "links": links, "topics": topics, "defaultBranch": branch}))
}
.await,
)
}
#[tool(
description = "Offer a public repository you own to another account, or with undo withdraw the offer. It moves, with its pulls, issues and forks, when that account accepts."
)]
async fn repo_transfer(
&self,
Parameters(p): Parameters<Transfer>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let to = match (p.to.as_deref(), p.undo) {
(Some(_), true) => anyhow::bail!("undo withdraws an offer: give no `to`"),
(None, false) => anyhow::bail!("give `to`, whom to offer it to, or undo"),
(Some(to), false) if to.contains('/') => Some(repo_url(to)?),
(Some(to), false) => Some(repo_url(&format!("{to}/{}", url.name))?),
(None, true) => None,
};
manage::transfer(&self.ctx, &url, to.as_ref()).await?;
Ok(json!({"offeredTo": to.map(|u| u.to_string())}))
}
.await,
)
}
#[tool(
description = "Archive a repository you own, making it read only, or unarchive it with undo. While archived, pushes and commits are refused, nobody can open pulls or issues or comment on it, and its workflows stop running. Answers when it was archived, or null once unarchived."
)]
async fn repo_archive(
&self,
Parameters(p): Parameters<Archive>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let at = manage::archive(&self.ctx, &url, !p.undo.unwrap_or(false)).await?;
Ok(json!({"archivedAt": at}))
}
.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 repo_accept(
&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 undo, take that back. Answers who may read and write it. Collaborators push through a gateway: sharing for writing before you have delegated your writes to one delegates them to the appview's first, as account_delegate does (approved in the browser), and says so after the answer."
)]
async fn repo_share(
&self,
Parameters(p): Parameters<Share>,
) -> Result<CallToolResult, McpError> {
let mut delegated = None;
let shared = async {
let url = repo_url(&p.repo)?;
if p.undo {
manage::unshare(&self.ctx, &url, &p.handle).await?;
} else {
// stdout carries the protocol; the link to approve goes to stderr.
let mut page = std::io::stderr();
let ctx = &self.ctx;
delegated =
manage::share_delegating(ctx, &url, &p.handle, p.write, &mut page).await?;
}
let value = manage::members(&self.ctx, &url).await?;
Ok(serde_json::to_value(value)?)
}
.await;
let mut answered = answer(shared)?;
let said = delegated.map(|g| format!("Delegated your writes to the gateway {g} first."));
answered.content.extend(said.map(ContentBlock::text));
Ok(answered)
}
#[tool(
description = "Invite an account to your organization, making your account an organization if it was not, or change a member's role (admin for an admin): with org, to an organization you are an admin of, through its gateway. They are a member once they join, within 7 days. Needs your writes delegated to a gateway first (account_delegate). Answers the roster (entries, each with its role and standing: invited, lapsed, public or hidden) and what changed (changed): the role given, where they stood before (was) and the role they had (had), first when it made your account an organization, and private when the invitation is kept in the organization's space, seen by it and its members only, rather than in its public records (a member already public stays there)."
)]
async fn org_add(&self, Parameters(p): Parameters<OrgAdd>) -> Result<CallToolResult, McpError> {
answer(
async {
let role = [org::Role::Member, org::Role::Admin][usize::from(p.admin)];
let invited = org::invite(&self.ctx, p.org.as_deref(), &p.handle, role).await;
self.changed(p.org, invited).await
}
.await,
)
}
#[tool(
description = "Take a member out of an organization, or withdraw an invitation: yours, or with org one you are an admin of, through its gateway. Answers the roster and what changed."
)]
async fn org_remove(
&self,
Parameters(p): Parameters<OrgRemove>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let removed = org::remove(&self.ctx, p.org.as_deref(), &p.handle).await;
self.changed(p.org, removed).await
}
.await,
)
}
#[tool(
description = "Accept an invitation to an organization, or change how your membership shows. Give public (anyone sees you are a member and you merge, close and label on all its repositories) or hidden (only its members see it, and on its public repositories you push and open pulls only). Answers the roster and what changed."
)]
async fn org_join(
&self,
Parameters(p): Parameters<OrgJoin>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let visibility = match (p.public, p.hidden) {
(true, false) => org::Visibility::Public,
(false, true) => org::Visibility::Hidden,
_ => anyhow::bail!("join needs exactly one of public or hidden"),
};
let joined = org::join(&self.ctx, &p.org, visibility).await;
self.changed(Some(p.org), joined).await
}
.await,
)
}
#[tool(
description = "Leave an organization: you can no longer push to its repositories. Answers the roster and what changed (left, and a warning if its gateway did not take you out of its spaces)."
)]
async fn org_leave(
&self,
Parameters(p): Parameters<OrgLeave>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let (left, warning) = org::leave(&self.ctx, &p.org).await?;
let warning = warning.map(|e| fix::said(&e, Words::Tools));
let changed = json!({"left": left, "warning": warning});
self.roster_after(Some(p.org), false, changed).await
}
.await,
)
}
#[tool(
description = "An organization's members, yours or an account's (org), as you may see them: an organization is any account with a dev.knotless.org record, each of its members named in a dev.knotless.orgMember record of their own and free to push to every repository it owns. Invitations and hidden members show only to the organization and its members; anyone else sees public members, as public shows anyone. Answers the roster (entries, each with its role and standing: invited, lapsed, public or hidden)."
)]
async fn org_members(
&self,
Parameters(p): Parameters<OrgMembers>,
) -> Result<CallToolResult, McpError> {
answer(self.roster_after(p.org, p.public, Value::Null).await)
}
#[tool(
description = "The organizations an account (yours by default) is a member of in public, each by DID and handle."
)]
async fn org_list(
&self,
Parameters(p): Parameters<OrgList>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let orgs = org::memberships_named(&self.ctx, p.handle.as_deref()).await?;
Ok(json!({ "organizations": orgs }))
}
.await,
)
}
#[tool(
description = "Take every member out of your organization and withdraw its invitations: your account is then no organization. Without yes, only says what that would do. Needs your writes delegated to a gateway first (account_delegate). Answers the roster and what changed (removed)."
)]
async fn org_delete(
&self,
Parameters(p): Parameters<OrgDelete>,
) -> Result<CallToolResult, McpError> {
answer(
async {
if !p.yes {
let me = knotless::session::me(&self.ctx).await?;
let roster = org::roster(&self.ctx, &me.did, false).await?;
if roster.organization {
anyhow::bail!(
"this takes out every member of {} and withdraws its invitations ({} in all): call again with yes to go ahead",
roster.name,
roster.entries.len()
);
}
}
let (_, entries) = org::delete(&self.ctx).await?;
self.roster_after(None, false, json!({"removed": entries})).await
}
.await,
)
}
#[tool(
description = "Protect a branch of a repository you own, or with undo, stop: collaborators can then only move it forward and never delete it, and unless merges is set, put no merge commits on it. With queue, pulls land on it only through the merge queue: merge queues them instead unless the owner forces it, and the gateway moves it for no collaborator but the runner that lands the queue. The gateway holds them to it. With reviews, a pull merges into it only once that many of the owner and collaborators, other than its author, approve it (comment_add with approve); with code_owners, only once, for each path it changes that the CODEOWNERS file on the branch (.knotless/CODEOWNERS, CODEOWNERS or .github/CODEOWNERS, the first there) gives owners, one of them approves it; with dismiss_stale, only approvals of its head as it is now count; with gates, accounts such as review agents, it waits for each one's review of its head, the merge queue holding it meanwhile (and ejecting it after the runner's --review-wait, 30 minutes unless set), and one of theirs asking for changes blocks it until that account reviews again, though they may not write; their approvals count toward nothing else, and an approval of the head from the owner or a collaborator, given after a gate's newest review, stands in for that gate's. A standing request for changes from the owner or a collaborator blocks it too, and the owner's force merges past all of it. Protecting a branch again replaces its rule. Answers the protected branches, as branch_rules does."
)]
async fn branch_protect(
&self,
Parameters(p): Parameters<Protect>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let mut gates = Vec::new();
for who in &p.gates {
let did = knotless::identity::resolve(&self.ctx, who).await?.did;
let unknown = Default::default();
gates.push(knotless::records::Gate { did, unknown });
}
let reviews = knotless::records::Reviews {
approvals: p.reviews,
code_owners: p.code_owners,
dismiss_stale: p.dismiss_stale,
gates,
..Default::default()
};
let rule = (!p.undo).then(|| {
knotless::records::Rule::new(&p.branch, p.merges, p.queue).reviewed(reviews)
});
let url = repo_url(&p.repo)?;
let rules = manage::protect(&self.ctx, &url, &p.branch, rule).await?;
self.rules(&p.repo, rules).await
}
.await,
)
}
#[tool(
description = "Fork someone else's 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 repo_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. The pull is a record in your own account: its at:// URI names you, not the repository's owner. Write the title and body from the repository's template: see pull_template first. A draft is not merged or queued until you mark it ready; reviewers are asked to review it, and told so once it is ready."
)]
async fn pull_create(
&self,
Parameters(p): Parameters<OpenPull>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let pictures = decoded(p.images)?;
let (body, images) = forge::pictured(&self.ctx, &url, p.body, pictures).await?;
let asks = forge::PullAsks {
draft: Some(p.draft),
add: p.reviewers.unwrap_or_default(),
remove: Vec::new(),
};
let uri = forge::open_pull(
&self.ctx,
&url,
&p.base,
&repo_url(&p.head_repo)?,
&p.head_branch,
&p.title,
body.as_deref(),
&images,
&asks,
)
.await?;
Ok(json!({"uri": uri}))
}
.await,
)
}
#[tool(
description = "The template a repository asks a pull's title and body to follow, from its .knotless/pull.md; null when it has none. Read it before pull_create: with a template, a pull with no body, or a body with none of its sections, is refused. Its HTML-comment notes are guidance, left out of the body if copied in."
)]
async fn pull_template(
&self,
Parameters(p): Parameters<PullTemplate>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let template =
forge::template(&self.ctx, &repo_url(&p.repo)?, "pull", p.base.as_deref())
.await?;
Ok(json!({"template": template}))
}
.await,
)
}
#[tool(
description = "The template a repository asks an issue's title and body to follow, from its .knotless/issue.md; null when it has none. Read it before issue_create: with a template, an issue with no body, or a body with none of its sections, is refused. Its HTML-comment notes are guidance, left out of the body if copied in."
)]
async fn issue_template(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let template =
forge::template(&self.ctx, &repo_url(&p.repo)?, "issue", None).await?;
Ok(json!({"template": template}))
}
.await,
)
}
#[tool(
description = "Open an issue on a repository. Write its title and body from the repository's template: see issue_template first."
)]
async fn issue_create(
&self,
Parameters(p): Parameters<OpenIssue>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let pictures = decoded(p.images)?;
let (body, images) = forge::pictured(&self.ctx, &url, p.body, pictures).await?;
let opened = forge::open_issue(&self.ctx, &url, &p.title, body.as_deref(), &images);
let uri = opened.await?;
Ok(json!({"uri": uri}))
}
.await,
)
}
async fn items(&self, p: Items, kind: &str) -> anyhow::Result<Value> {
let filter = Query::parse(p.query.as_deref().unwrap_or_default());
let me = knotless::session::me(&self.ctx).await.ok();
let filter = filter
.resolve(&self.ctx, me.as_ref().map(|m| m.did.as_str()))
.await;
let items: Vec<forge::Item> = forge::list(&self.ctx, &repo_url(&p.repo)?)
.await?
.into_iter()
.filter(|i| i.kind == kind)
.filter(|i| filter.matches(i) && filter.in_state(i))
.collect();
let mut listed = serde_json::to_value(&items)?;
// A pull says it is no draft, as plainly as that it is one.
let values = listed.as_array_mut().into_iter().flatten();
for (item, value) in items.iter().zip(values) {
if item.kind == "pull" {
value["draft"] = Value::from(item.draft);
}
}
Ok(listed)
}
#[tool(
description = "Pull requests on a repository, newest first, with their state, labels and comment counts, and draft: true or false. `query` filters them as GitHub's search does: `label:kind/bug -label:area/web author:alice.test assignee:@me state:open crash`."
)]
async fn pull_list(
&self,
Parameters(p): Parameters<Items>,
) -> Result<CallToolResult, McpError> {
answer(self.items(p, "pull").await)
}
#[tool(
description = "Issues on a repository, newest first, with their state, labels and comment counts. `query` filters them as GitHub's search does: `label:kind/bug -label:area/web author:alice.test assignee:@me state:open crash`."
)]
async fn issue_list(
&self,
Parameters(p): Parameters<Items>,
) -> Result<CallToolResult, McpError> {
answer(self.items(p, "issue").await)
}
async fn show(&self, p: Subject) -> anyhow::Result<Value> {
let url = repo_url(&p.repo)?;
let mut thread = forge::show(&self.ctx, &url, &p.uri).await?;
// Those asked to review it and those who did, by handle too.
let reviewers = thread.item.reviews.iter().map(|r| r.by.as_str());
let asked = thread.item.reviewers.iter().map(String::as_str);
let assigned = thread.item.assignees.iter().map(String::as_str);
let all = asked.chain(reviewers).chain(assigned);
let named = knotless::identity::handles(&self.ctx, all).await;
thread.handles.extend(named);
let handle = |did: &str| thread.handles.get(did).cloned();
// A thread's answers stand as it does; a review's own comment
// names itself, as its line comments name it.
let started = |c: &forge::Entry| c.value["reply"].as_str().unwrap_or(&c.uri).to_string();
let reviewed = |c: &forge::Entry| {
let named = thread
.comments
.iter()
.any(|o| o.value["review"] == c.uri.as_str());
let review = c.value["review"].as_str().map(str::to_string);
let own = (c.value["verdict"].is_string() || named).then(|| c.uri.clone());
review.or(own)
};
// A review its reviewer's later one stands in place of.
let held = &thread.item.reviews;
let standing = |c: &forge::Entry| held.iter().any(|r| r.uri == c.uri);
let author = thread.item.author.as_str();
let comments: Vec<Value> = thread
.comments
.iter()
.map(|c| {
let mut said = json!({
"uri": c.uri,
"author": c.author,
"handle": thread.handles.get(&c.author),
"body": c.value["body"],
"facets": c.value["facets"],
"verdict": c.value["verdict"],
"commit": c.value["commit"],
"line": lines(&c.value["line"]),
"reply": c.value["reply"],
"review": reviewed(c),
"dismisses": c.value["dismisses"],
"createdAt": c.value["createdAt"],
"updatedAt": c.value["updatedAt"],
"reactions": thread.reactions.get(&c.uri),
});
// Only a thread on lines is resolved, or not.
if c.value["line"].is_object() || c.value["reply"].is_string() {
said["resolved"] = thread.resolved.contains(&started(c)).into();
}
let verdict = c.value["verdict"].is_string() && c.author != author;
if verdict && !standing(c) {
said["superseded"] = true.into();
}
said
})
.collect();
let reactions = thread.reactions.get(&thread.item.uri);
let compared = match thread.item.kind == "pull" {
true => Some(forge::compare(&self.ctx, &url, &p.uri).await),
false => None,
};
// Against the head as it is now: a merged pull's reviews
// stand as they were.
let open = thread.item.state == "open";
let now = compared.as_ref().and_then(|c| c.as_ref().ok());
let now = now.map(|c| c.head.as_str()).filter(|_| open);
let reviews: Vec<Value> = (thread.item.reviews.iter())
.map(|r| {
let writer = thread.writers.contains(&r.by);
let head = now.or(r.commit.as_deref()).unwrap_or_default();
let rule = thread.asks.as_ref();
let not = rule.and_then(|rule| approvals::uncounted(rule, r, writer, head));
json!({
"by": r.by,
"handle": handle(&r.by),
"verdict": r.verdict,
"commit": r.commit,
"uri": r.uri,
"outdated": now.is_some_and(|now| !r.at_head(now)),
"maintainer": writer,
"uncounted": not,
"dismissed": r.dismissed,
})
})
.collect();
let mut item = serde_json::to_value(&thread.item)?;
// A pull says it is no draft, as plainly as that it is one.
if thread.item.kind == "pull" {
item["draft"] = Value::from(thread.item.draft);
}
if !thread.item.reviews.is_empty() {
item["reviews"] = Value::from(reviews);
}
if !thread.item.reviewers.is_empty() {
// Waited on while the pull is open and they have not
// reviewed it; moot once it is merged or closed.
let done = |did: &str| !open || held.iter().any(|r| r.by == did);
let asked = thread.item.reviewers.iter();
let asked =
asked.map(|did| json!({"did": did, "handle": handle(did), "pending": !done(did)}));
item["reviewers"] = asked.collect();
}
if !thread.item.assignees.is_empty() {
let them = thread.item.assignees.iter();
let them = them.map(|did| json!({"did": did, "handle": handle(did)}));
item["assignees"] = them.collect();
}
let mut out = json!({"item": item, "comments": comments, "reactions": reactions});
if !thread.mentioned_by.is_empty() {
out["mentionedBy"] = serde_json::to_value(&thread.mentioned_by)?;
}
if let Some(compared) = compared {
out["compare"] = match compared {
Ok(compared) => serde_json::to_value(compared)?,
Err(err) => json!({"error": format!("{err:#}")}),
};
}
// Queued or ejected and open: where it stands, in words too.
let item = &thread.item;
let queued = item.queue.as_ref().filter(|q| q.state != "left");
if queued.is_some() && item.state == "open" {
let w = knotless::queue::standing(&self.ctx, &url, &p.uri).await?;
out["standing"] = json!({
"said": w.as_ref().map(|w| w.said()),
"place": w.as_ref().and_then(|w| w.place.clone()),
"batch": w.and_then(|w| w.batch),
});
}
if let Some(said) = knotless::auto_merge::said(&self.ctx, &url, item).await? {
out["autoMerge"] = json!(said);
}
Ok(out)
}
#[tool(
description = "A pull request or issue with its comments and reviews, oldest first. Each comment has its author's DID and handle, and `facets`, the accounts its body mentions, by where. A comment on a pull's lines has `line` ({path, side, line, startLine}), the `commit` they are in, and the `review` it was sent with: a review's own comment, the one with its verdict or body, names itself there. A comment in a thread on a pull's lines, the first or an answer, has `resolved`, true once the thread is (other comments have none); an answer has `reply`, the comment starting it. A review that its reviewer's later one stands in place of has `superseded`, and a comment that dismissed a review names it as `dismisses`. `item.assignees` are those the owner or a collaborator assigns it to ({did, handle}). A pull's `item.reviewers` are those asked to review it, by its author or by the owner or a collaborator ({did, handle, pending}: pending while the pull is open and they have not reviewed it, false once it is merged or closed), and `item.reviews` where each reviewer stands: by (DID), handle, verdict, the commit it looked at, uri, `outdated` once the open pull has moved past that commit, `maintainer` for the owner or a collaborator, `dismissed`, the comment that dismissed it (pull_dismiss_review), when one did, and `uncounted`, why an approval does not count toward what its target branch's rule asks (only the owner's and collaborators' count; with dismiss_stale, only those of its head as it is now). 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); for a merged one, what its merge brought in, from where the base was before it to the merge, though its branch is gone. A merged pull that a merged revert undid names it as `item.revertedBy`, and the revert names the pull as `item.reverts`. An open pull in its branch's merge queue, or ejected from it, has `standing`: its place, the batch it is in while that is checked or landing, and `said`, where it stands in words. An open pull ever set to merge when its checks pass (pull_merge with auto) has `autoMerge`, in words: set and by whom, or cancelled and why, as by a push from someone who may not merge it."
)]
async fn pull_show(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(self.show(p).await)
}
#[tool(
description = "A pull request or issue with its comments and reviews, oldest first. Each comment has its author's DID and handle, and `facets`, the accounts its body mentions, by where. A comment on a pull's lines has `line` ({path, side, line, startLine}), the `commit` they are in, and the `review` it was sent with: a review's own comment, the one with its verdict or body, names itself there. A comment in a thread on a pull's lines, the first or an answer, has `resolved`, true once the thread is (other comments have none); an answer has `reply`, the comment starting it. A review that its reviewer's later one stands in place of has `superseded`, and a comment that dismissed a review names it as `dismisses`. `item.assignees` are those the owner or a collaborator assigns it to ({did, handle}). A pull's `item.reviewers` are those asked to review it, by its author or by the owner or a collaborator ({did, handle, pending}: pending while the pull is open and they have not reviewed it, false once it is merged or closed), and `item.reviews` where each reviewer stands: by (DID), handle, verdict, the commit it looked at, uri, `outdated` once the open pull has moved past that commit, `maintainer` for the owner or a collaborator, `dismissed`, the comment that dismissed it (pull_dismiss_review), when one did, and `uncounted`, why an approval does not count toward what its target branch's rule asks (only the owner's and collaborators' count; with dismiss_stale, only those of its head as it is now). 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); for a merged one, what its merge brought in, from where the base was before it to the merge, though its branch is gone. A merged pull that a merged revert undid names it as `item.revertedBy`, and the revert names the pull as `item.reverts`. An open pull in its branch's merge queue, or ejected from it, has `standing`: its place, the batch it is in while that is checked or landing, and `said`, where it stands in words."
)]
async fn issue_show(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(self.show(p).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; for a merged pull, what its merge brought in, though its branch is gone. Each line of a hunk starts with two numbers, its line in the old file and in the new, the one it is not on left blank, then ` | ` and the line as the diff has it. To comment on it with comment_add's `lines`: a `+` line is `side: new` at its new number, a `-` line `side: old` at its old number, and an unchanged line `side: new` at its new number. A diff too long to give whole answers instead with `said` and `files`, each file's `path`, `added`, `removed` and `lines`: ask again with `paths`, a few files at a time, for their parts of it."
)]
async fn pull_diff(
&self,
Parameters(p): Parameters<PullDiff>,
) -> Result<CallToolResult, McpError> {
answer(self.diff(p).await)
}
/// A pull's diff, as `pull_diff` answers it, and its resource too.
async fn diff(&self, p: PullDiff) -> anyhow::Result<Value> {
let diff = forge::pull_diff(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?;
pull_diff_answer(&diff, &p.paths, WHOLE_DIFF)
}
#[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 branch_rename(
&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.name, &p.new_name).await?;
Ok(json!({"branch": p.new_name, "retargeted": moved}))
}
.await,
)
}
#[tool(
description = "Comment on a pull request or issue. With approve or changes, review someone else's pull at its current head: each reviewer's latest verdict is where they stand, as `item.reviews` in pull_show and pull_list says. With lines, comment on lines of a pull's diff at its head, each starting a thread, all sent at once as one review (with a body or verdict) or on their own. Given a comment's URI, answer it in its thread. Returns `uri`, what it wrote first, and `written`, the URIs of all it wrote in order; for an approval from someone who may not write to the repository, `note`, that it does not count toward a protected branch's reviews."
)]
async fn comment_add(
&self,
Parameters(p): Parameters<Remark>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let pictures = decoded(p.images)?;
let said = forge::pictured(&self.ctx, &url, Some(p.body), pictures).await?;
let (body, images) = (said.0.unwrap_or_default(), said.1);
let verdict = match (p.approve, p.changes) {
(true, true) => anyhow::bail!("give approve or changes, not both"),
(true, false) => Some("approve"),
(false, true) => Some("changes"),
(false, false) => None,
};
let lines: Vec<forge::LineComment> = p
.lines
.unwrap_or_default()
.into_iter()
.map(|l| forge::LineComment {
line: LineRef {
path: l.path,
side: l.side.filter(|s| s != "new"),
line: l.line,
start_line: l.start_line,
},
body: l.body,
})
.collect();
let said = forge::comment(&self.ctx, &url, &p.uri, &body, verdict, &images, &lines);
let wrote = said.await?;
let mut out = json!({"uri": wrote[0], "written": wrote});
if verdict == Some("approve") {
let me = knotless::session::me(&self.ctx).await?;
let t = forge::Target::open(&self.ctx, &url).await?;
let writer = me.did == t.remote.did() || t.record.is_collaborator(&me.did);
let not = format!(
"your approval does not count toward a protected branch's reviews: {}",
approvals::NOT_WRITER
);
out["note"] = Value::from((!writer).then_some(not));
}
Ok(out)
}
.await,
)
}
/// Changes your pull, issue or comment `uri`.
async fn edited(
&self,
repo: &str,
uri: &str,
title: Option<String>,
body: Option<String>,
images: Option<Vec<Picture>>,
draft: Option<bool>,
) -> anyhow::Result<Value> {
let url = repo_url(repo)?;
let pictures = decoded(images)?;
if body.is_none() && !pictures.is_empty() {
anyhow::bail!("images go with a body, the new one they are shown after");
}
let (body, images) = forge::pictured(&self.ctx, &url, body, pictures).await?;
let asks = forge::PullAsks {
draft,
add: Vec::new(),
remove: Vec::new(),
};
let body = body.as_deref();
forge::edit(&self.ctx, &url, uri, title.as_deref(), body, &images, &asks).await?;
Ok(json!({"edited": uri}))
}
#[tool(
description = "Change what your pull request says: its title and body, and whether it is a draft (draft) or ready to merge (ready). Only its author can. This is the way to update a pull's description, and to mark a draft ready. Whom it asks to review is pull_ask_review's."
)]
async fn pull_edit(
&self,
Parameters(p): Parameters<EditPull>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let draft = match (p.draft, p.ready) {
(true, true) => anyhow::bail!("give draft or ready, not both"),
(true, false) => Some(true),
(false, true) => Some(false),
(false, false) => None,
};
self.edited(&p.repo, &p.uri, p.title, p.body, p.images, draft)
.await
}
.await,
)
}
#[tool(description = "Change what your issue says: its title and body. Only its author can.")]
async fn issue_edit(
&self,
Parameters(p): Parameters<EditIssue>,
) -> Result<CallToolResult, McpError> {
answer(
self.edited(&p.repo, &p.uri, p.title, p.body, p.images, None)
.await,
)
}
#[tool(description = "Change what your comment or review says: its body. Only its author can.")]
async fn comment_edit(
&self,
Parameters(p): Parameters<EditComment>,
) -> Result<CallToolResult, McpError> {
answer(
self.edited(&p.repo, &p.uri, None, Some(p.body), p.images, None)
.await,
)
}
#[tool(
description = "Delete your comment or review. Only its author can; a repository's owner hides others' with mod_hide instead. Pulls and issues are closed with pull_close or issue_close, not deleted."
)]
async fn comment_delete(
&self,
Parameters(p): Parameters<Unsay>,
) -> Result<CallToolResult, McpError> {
answer(
async {
forge::delete_comment(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?;
Ok(json!({"deleted": p.uri}))
}
.await,
)
}
#[tool(
description = "The labels a repository's pulls and issues may carry: each one's name, and its description, color and whether it is exclusive in its scope when it has them."
)]
async fn label_list(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let labels = manage::labels(&self.ctx, &repo_url(&p.repo)?).await?;
Ok(serde_json::to_value(labels)?)
}
.await,
)
}
#[tool(
description = "Define a label on a repository you own, in place of any of its name. A name's scope is what comes before its last /, as kind in kind/bug; of a scope's exclusive labels only one stands at a time. Answers the labels it defines."
)]
async fn label_define(
&self,
Parameters(p): Parameters<DefineLabel>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let label = Some(knotless::records::Label {
description: p.description,
color: p.color,
exclusive: p.exclusive,
..knotless::records::Label::new(&p.name)
});
let url = repo_url(&p.repo)?;
let labels = manage::define_label(&self.ctx, &url, &p.name, label).await?;
Ok(serde_json::to_value(labels)?)
}
.await,
)
}
#[tool(
description = "Take a label away from a repository you own, off every pull and issue it is on. Answers the labels it defines."
)]
async fn label_delete(
&self,
Parameters(p): Parameters<RepoName>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let labels = manage::define_label(&self.ctx, &url, &p.name, None).await?;
Ok(serde_json::to_value(labels)?)
}
.await,
)
}
#[tool(
description = "The accounts, such as a triage bot, whose labels of some scopes a repository counts, below the owner's and collaborators' whenever they were set."
)]
async fn triager_list(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let triagers = manage::triagers(&self.ctx, &repo_url(&p.repo)?).await?;
Ok(serde_json::to_value(triagers)?)
}
.await,
)
}
#[tool(
description = "As owner, name an account, such as a triage bot, as a triager of some scopes (kind for kind/bug), in place of what it was: its labels of them count, below the owner's and collaborators' whenever they were set. Answers the triagers."
)]
async fn triager_add(
&self,
Parameters(p): Parameters<TriagerAdd>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let scopes: Vec<&str> = p.scopes.iter().map(String::as_str).collect();
let set = manage::set_triager(&self.ctx, &url, &p.handle, Some(&scopes)).await?;
Ok(serde_json::to_value(set.0)?)
}
.await,
)
}
#[tool(description = "As owner, stop naming an account as a triager. Answers the triagers.")]
async fn triager_remove(
&self,
Parameters(p): Parameters<RepoHandle>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let set = manage::set_triager(&self.ctx, &url, &p.handle, None).await?;
Ok(serde_json::to_value(set.0)?)
}
.await,
)
}
async fn labelled(&self, p: LabelItem) -> anyhow::Result<Value> {
let names: Vec<&str> = p.labels.iter().map(String::as_str).collect();
let url = repo_url(&p.repo)?;
let labels = forge::label(&self.ctx, &url, &p.uri, &names, p.undo).await?;
Ok(json!({"labels": labels}))
}
#[tool(
description = "Put labels on a pull request, or with undo, take them off: only those the repository defines (see label_list). The owner and collaborators may, and triagers (see triager_list) for the scopes named. Answers the labels it carries now."
)]
async fn pull_label(
&self,
Parameters(p): Parameters<LabelItem>,
) -> Result<CallToolResult, McpError> {
answer(self.labelled(p).await)
}
#[tool(
description = "Put labels on an issue, or with undo, take them off: only those the repository defines (see label_list). The owner and collaborators may, and triagers (see triager_list) for the scopes named. Answers the labels it carries now."
)]
async fn issue_label(
&self,
Parameters(p): Parameters<LabelItem>,
) -> Result<CallToolResult, McpError> {
answer(self.labelled(p).await)
}
async fn assigned(
&self,
p: AssignItem,
role: knotless::records::Role,
) -> anyhow::Result<Value> {
let names: Vec<&str> = p.handles.iter().map(String::as_str).collect();
let url = repo_url(&p.repo)?;
let item = forge::assign(&self.ctx, &url, &p.uri, role, &names, p.undo).await?;
Ok(json!({"assignees": item.assignees, "reviewers": item.reviewers}))
}
#[tool(
description = "Assign a pull request to accounts, or with undo, take them off it. The owner and collaborators may. Answers whom it is assigned to now and who is asked to review it, by DID."
)]
async fn pull_assign(
&self,
Parameters(p): Parameters<AssignItem>,
) -> Result<CallToolResult, McpError> {
answer(self.assigned(p, knotless::records::Role::Assignee).await)
}
#[tool(
description = "Assign an issue to accounts, or with undo, take them off it. The owner and collaborators may. Answers whom it is assigned to now, by DID."
)]
async fn issue_assign(
&self,
Parameters(p): Parameters<AssignItem>,
) -> Result<CallToolResult, McpError> {
answer(self.assigned(p, knotless::records::Role::Assignee).await)
}
#[tool(
description = "Ask accounts to review a pull, or with undo, stop asking. The owner and collaborators ask for reviews besides a pull's author, who asks in the pull itself. A pull's author stops asking only those it asked, and the owner and collaborators those they asked. Answers whom it is assigned to now and who is asked to review it, by DID."
)]
async fn pull_ask_review(
&self,
Parameters(p): Parameters<AssignItem>,
) -> Result<CallToolResult, McpError> {
answer(self.assigned(p, knotless::records::Role::Reviewer).await)
}
#[tool(
description = "Dismiss a reviewer's standing review of a pull (reviewer, a handle or DID, or the review's at:// URI), saying why (reason): a comment on the pull naming it, so it counts for nothing, for or against, until they review again. A dismissed gate's review lets the pull go without it. The owner and collaborators may, on their own pulls too, where their approval never counts. Answers the comment's uri."
)]
async fn pull_dismiss_review(
&self,
Parameters(p): Parameters<DismissReview>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let reviewer = match p.reviewer.starts_with("at://") {
true => knotless::aturi::parts(&p.reviewer)?.0,
false => p.reviewer.as_str(),
};
let uri = forge::dismiss(&self.ctx, &url, &p.uri, reviewer, &p.reason).await?;
Ok(json!({ "uri": uri }))
}
.await,
)
}
/// Closes or reopens a pull or issue, or resolves a thread.
async fn stated(&self, p: Subject, state: &str) -> anyhow::Result<Value> {
forge::set_state(&self.ctx, &repo_url(&p.repo)?, &p.uri, state).await?;
Ok(json!({"state": state}))
}
#[tool(description = "Close a pull request: its author, the owner and collaborators may.")]
async fn pull_close(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(self.stated(p, "closed").await)
}
#[tool(
description = "Reopen a closed pull request: its author, the owner and collaborators may."
)]
async fn pull_reopen(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(self.stated(p, "open").await)
}
#[tool(description = "Close an issue: its author, the owner and collaborators may.")]
async fn issue_close(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(self.stated(p, "closed").await)
}
#[tool(description = "Reopen a closed issue: its author, the owner and collaborators may.")]
async fn issue_reopen(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(self.stated(p, "open").await)
}
#[tool(
description = "Resolve the thread a comment on a pull's lines starts, or with undo open it again: the thread's author, the pull's, the owner and collaborators may."
)]
async fn comment_resolve(
&self,
Parameters(p): Parameters<ResolveComment>,
) -> Result<CallToolResult, McpError> {
let state = ["resolved", "open"][usize::from(p.undo)];
let thread = Subject {
repo: p.repo,
uri: p.uri,
};
answer(self.stated(thread, state).await)
}
#[tool(
description = "Merge an open pull request: a merge commit naming the pull, even where a fast-forward could have been; into a branch protected as linear, one squashed commit. One whose head is already on the branch, pushed there by hand, is marked merged as it stands. Fails on conflicts. In a repository that names runners, only once every workflow that runs on the pull has passed at its head merged with the target branch as it is now: it refuses, naming what failed, what is still running, or the older head or base its checks are of, and run_list with pull and log shows why. With wait, it waits up to that many seconds for checks running or not yet run, and the owner's merge asks the runners once to run a head and base none has; force, the owner's alone, merges whatever they say. Into a branch protected with queue (branch_rules), it queues the pull instead, as queue does, and answers queued with why, unless the owner forces it. Its head branch, when in this repository, is deleted with it unless keep_branch or another open pull names it. The owner and collaborators may. Into the repository's default branch, it closes each open issue on it that the pull's title or body names right after fixes, closes or resolves (or fix, fixed, close, closed, resolve, resolved), by its page URL (https://<appview>/<owner>/<repo>/issue/<did>/<rkey>) or at:// URI, returning them as closed; the merge queue does the same as it lands. With queue, it puts the pull in its branch's merge queue instead and returns at once with its place (queued: position of of), and, queued again at the very head the queue last ejected, why it was (queued.ejectedBefore), as it will be again unless that was not the pull's doing: the quickest way to land, as a runner checks queued pulls together a batch at a time, and there is no need to wait. Queue a pull once it builds, though: one that does not fails its whole batch, and every pull in it waits while the queue halves the batch to find it. Merge the target branch into the pull's branch just before queueing: one that conflicts with it is refused. Ejected, read why with pull_show and the failed checks' logs with run_list (pull and log), fix it, and queue it again; queue the same head again only when the log shows the failure is not the pull's, such as a test it does not touch that fails other pulls too. One that conflicts only with a pull ahead of it waits for that pull to land or leave, and is ejected then if it conflicts with the branch: merge the branch into it. Ejected again for the same reason, stop and say so rather than queue it once more. queue_list shows where it is, and pull_show whether it merged, or where it stands (standing.said: Queued, 3rd of 5, running its checks, ready for the next batch or waiting for its review, from a gate its branch names; In a batch of 4 being checked; Landing; or why it was ejected and which checks failed, also under item.queue.reason and item.queue.checks). Into a branch protected with reviews, it refuses until the pull is reviewed as the rule asks (see branch_protect), naming what is missing. With check, it merges nothing and says whether it would now: state clean, conflicting (conflicts, the paths) or blocked by its checks (checks: passed, failed, running, pending, or none when the repository names no runners) or its reviews (reviews: passed, blocked, or none when its branch asks for none; reviewsMissing, what they lack, as reviews 1 of 2, owners of web/: @alice or @bob, or a review of its head from @bot; reviewsUncounted, the approvals that do not count and why; codeOwnersWarnings, the CODEOWNERS lines skipped and why); reason, why not, in the words a merge would refuse with, and that you may not merge it when you may not; method (merge or squash); deletes, the branch it would delete; and may, what your part in the repository lets you do to it (merge, force, queue, base and head as pull_retarget changes them, rerun): not whether it would work now, which state and reason say. A blocked pull's merge is refused, the owner's too, unless the owner forces it. queueOnly, when its branch takes pulls only through the merge queue. Merged, it answers the commit its branch is at now (sha), how (method: merge or squash), the branch it deleted (deleted) or why it kept the head branch (kept: asked to, in another repository such as a fork, the branch it merges into or the default branch, or named by another open pull), and the issues it closed. With auto, on a branch with no merge queue, it sets the pull to merge by itself once its checks pass at its head merged with the branch, reviewed as the branch asks, and returns at once (autoMerge: set, commit: the head it was set at): the runner that lands the repository's merge queues merges it then, keeping its branch. It stays set while a check fails or it conflicts, until a push fixes that; a push by anyone who may not merge it, or retargeting it, cancels it. pull_show says where it stands (autoMerge). With auto and undo, it cancels that (autoMerge: cancelled): the owner, collaborators and the pull's author may. undo without auto is ignored."
)]
async fn pull_merge(
&self,
Parameters(p): Parameters<MergePull>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
if p.auto && p.undo {
knotless::auto_merge::cancel(&self.ctx, &url, &p.uri).await?;
return Ok(json!({"autoMerge": "cancelled"}));
}
if p.auto {
let head = knotless::auto_merge::set(&self.ctx, &url, &p.uri).await?;
return Ok(json!({"autoMerge": "set", "commit": head}));
}
if p.check {
let me = knotless::session::me(&self.ctx).await?;
let found =
landing::mergeable(&self.ctx, &url, &p.uri, &me.did, Words::Tools).await?;
return Ok(serde_json::to_value(found)?);
}
if p.queue {
let at = knotless::queue::enqueue(&self.ctx, &url, &p.uri).await?;
return Ok(json!({"queued": at}));
}
let gate = forge::Gate {
wait: Duration::from_secs(p.wait.unwrap_or(0).min(MOST_WAIT)),
force: p.force,
};
let landed =
knotless::queue::merge_or_queue(&self.ctx, &url, &p.uri, p.keep_branch, gate);
let landed = landed.await;
match landed? {
knotless::queue::Landed::Merged(m) => Ok(json!({
"sha": m.sha,
"method": m.method,
"deleted": m.deleted,
"kept": m.kept,
"closed": m.closed,
})),
knotless::queue::Landed::Queued(at) => {
let why = format!("{} takes pulls only through the merge queue", at.base);
Ok(json!({"queued": at, "why": why}))
}
}
}
.await,
)
}
#[tool(
description = "Revert a merged pull request, as GitHub's Revert button does: one commit undoing everything its merge brought in, on a new branch revert/<rkey of the pull> cut from the target branch as it is now, and a pull of that branch into the target branch, titled Revert \"<title>\", its body linking the pull and saying reason. Nothing merges: the revert is a pull like any other, to check and merge or queue. Fails on a pull that is not merged, when undoing it conflicts with what the branch took since (naming the paths), and when the branch holds nothing it brought in any more. The owner and collaborators may. Answers the new pull's at:// URI (pull), its branch and its commit."
)]
async fn pull_revert(
&self,
Parameters(p): Parameters<RevertPull>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let reason = p.reason.as_deref();
let reverted = knotless::revert::revert(&self.ctx, &url, &p.uri, reason).await?;
Ok(serde_json::to_value(reverted)?)
}
.await,
)
}
#[tool(
description = "Bring an open pull request's branch up to date with the branch it merges into, as GitHub's Update branch button does: a merge commit of that branch's tip into the pull's branch, pushed there, no checkout needed. Where both sides only added lines at the same place, as two pulls each adding a section to one docs page, it keeps the base's lines first and the pull's after, and names those paths (added). Any other conflict fails, naming the paths, for the author to merge by hand; so does a push to the branch since it was read. The owner and collaborators may, for a pull whose branch is in the repository itself. Answers the branch, the merge commit (none when the branch held the base already) and added."
)]
async fn pull_update_branch(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let updated =
knotless::branch_update::update_branch(&self.ctx, &url, &p.uri).await?;
Ok(serde_json::to_value(updated)?)
}
.await,
)
}
#[tool(
description = "A repository's merge queues: the pulls queued with pull_merge and queue, in the order they land, each with its place in its branch's queue and, while its batch is being checked or landing, that batch (batch: the commit it would leave the branch at, its pulls, state checking or landing, and its checks); then the open pulls the queue ejected, each with why under queue.reason and the checks that failed under queue.checks. A runner the repository names takes queued pulls a batch at a time, squashes each onto the branch as a commit of its own, runs the checks once at the last, and moves the branch there once they pass, marking each merged; a pull that conflicts, or that breaks the checks (found by halving a failing batch), it ejects. run_list with queue shows a batch's runs."
)]
async fn queue_list(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let waiting = knotless::queue::list(&self.ctx, &repo_url(&p.repo)?).await?;
Ok(serde_json::to_value(waiting)?)
}
.await,
)
}
#[tool(
description = "Take a pull out of its branch's merge queue. The owner and collaborators may."
)]
async fn queue_leave(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(
async {
knotless::queue::leave(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?;
Ok(json!({"left": p.uri}))
}
.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 pull_retarget(
&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 with undo show it again."
)]
async fn mod_hide(&self, Parameters(p): Parameters<Hide>) -> Result<CallToolResult, McpError> {
answer(
async {
let hidden = !p.undo;
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 with undo unblock it: nothing it writes about your repositories, or about the one repository named, shows."
)]
async fn mod_block(
&self,
Parameters(p): Parameters<Block>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let blocked = !p.undo;
let repo = p.repo.as_deref().map(repo_url).transpose()?;
let did = moderation::block(&self.ctx, &p.handle, repo.as_ref(), blocked)
.await?
.did;
Ok(json!({"did": did, "blocked": blocked}))
}
.await,
)
}
#[tool(
description = "The accounts an account blocks, yours by default, the latest first: each by DID and handle, with the one repository it is blocked from (as it was named when blocked), or none for all of them."
)]
async fn mod_blocks(
&self,
Parameters(p): Parameters<Blocks>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let who = match p.handle {
Some(who) => who,
None => knotless::session::me(&self.ctx).await?.did,
};
let blocks = moderation::blocks(&self.ctx, &who).await?;
let dids = blocks.iter().map(|b| b.subject.as_str());
let handles = knotless::identity::handles(&self.ctx, dids).await;
let listed: Vec<Value> = blocks
.iter()
.map(|b| {
json!({
"did": b.subject,
"handle": handles.get(&b.subject),
"repo": b.repo.as_ref().map(|r| r.uri.clone()),
"uri": b.uri,
"createdAt": b.created_at,
})
})
.collect();
Ok(json!({ "blocks": listed }))
}
.await,
)
}
#[tool(
description = "How many email addresses you claim: commits authored with one show as yours on repositories you own or push to."
)]
async fn account_email(&self) -> Result<CallToolResult, McpError> {
answer(
async {
let me = knotless::session::me(&self.ctx).await?;
let claimed = knotless::emails::claimed(&self.ctx, &me.did).await?;
Ok(json!({"claimed": claimed.len()}))
}
.await,
)
}
/// Claims `address`, or takes the claim back.
async fn claim(&self, address: &str, on: bool) -> anyhow::Result<Value> {
let me = knotless::session::me(&self.ctx).await?;
let set = knotless::emails::set(&self.ctx, &me, address, on).await?;
Ok(json!({"changed": set.changed, "claimed": set.hashes.len()}))
}
#[tool(
description = "Claim an email address as yours: commits authored with it show as yours on repositories you own or push to. The claim keeps only a hash of it, salted with your DID. Says how many you claim, and whether this changed anything."
)]
async fn account_email_add(
&self,
Parameters(p): Parameters<Email>,
) -> Result<CallToolResult, McpError> {
answer(self.claim(&p.address, true).await)
}
#[tool(
description = "Take back a claim of an email address. Says how many you claim, and whether this changed anything."
)]
async fn account_email_remove(
&self,
Parameters(p): Parameters<Email>,
) -> Result<CallToolResult, McpError> {
answer(self.claim(&p.address, false).await)
}
#[tool(
description = "Which notifications you hear of, as the appview keeps it: each reason on or off (star, follow, pull, issue, comment, mention, review-requested, invite, checks-failed, ejected, subscribed, watching), and whether only of what accounts you follow did (followed_only), or anyone (anyone). Changes what is given first; with nothing given, only says."
)]
async fn notification_settings(
&self,
Parameters(p): Parameters<NotificationSettings>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let followed_only = match (p.followed_only, p.anyone) {
(true, true) => anyhow::bail!("give followed_only or anyone, not both"),
(true, false) => Some(true),
(false, true) => Some(false),
(false, false) => None,
};
let change = knotless::notifications::Change {
on: p.on.unwrap_or_default(),
off: p.off.unwrap_or_default(),
followed_only,
};
let now = knotless::notifications::preferences(&self.ctx, &change).await?;
let reasons: Vec<Value> = now
.reasons
.iter()
.map(|r| json!({"reason": r.reason, "enabled": r.enabled}))
.collect();
Ok(json!({"reasons": reasons, "followed_only": now.followed_only}))
}
.await,
)
}
/// What you hear of `subject` now, after choosing `level`.
async fn chose(&self, subject: &str, level: &str) -> anyhow::Result<Value> {
let now = knotless::notifications::subscribe(&self.ctx, subject, level).await?;
Ok(serde_json::to_value(now)?)
}
#[tool(
description = "Choose what you hear of a repository, as the appview keeps it for you alone: watch it for all it does (all: pulls and issues opened, comments, releases), for its releases alone (releases), or ignore it (ignore); participating forgets the choice, so you hear as your part in it says. Says what you hear of it now."
)]
async fn notification_watch(
&self,
Parameters(p): Parameters<Watch>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let subject =
knotless::notifications::repo_subject(&self.ctx, &repo_url(&p.repo)?).await?;
self.chose(&subject, p.level.as_deref().unwrap_or("all"))
.await
}
.await,
)
}
#[tool(
description = "Hear of every comment on a pull or an issue, as the appview keeps it for you alone. Says what you hear of it now."
)]
async fn notification_subscribe(
&self,
Parameters(p): Parameters<Uri>,
) -> Result<CallToolResult, McpError> {
answer(self.chose(&p.uri, "subscribed").await)
}
#[tool(
description = "Hear nothing of a pull or an issue but a review asked of you, as the appview keeps it for you alone. Says what you hear of it now."
)]
async fn notification_mute(
&self,
Parameters(p): Parameters<Uri>,
) -> Result<CallToolResult, McpError> {
answer(self.chose(&p.uri, "muted").await)
}
#[tool(
description = "Hear of a pull or an issue as your part in it says, as before notification_subscribe or notification_mute. Says what you hear of it now."
)]
async fn notification_unsubscribe(
&self,
Parameters(p): Parameters<Uri>,
) -> Result<CallToolResult, McpError> {
answer(self.chose(&p.uri, "participating").await)
}
#[tool(
description = "Every choice you made of what you hear of: the repositories you watch or ignore (notification_watch) and the pulls and issues you subscribed to or muted, the latest first."
)]
async fn notification_subscriptions(&self) -> Result<CallToolResult, McpError> {
answer(
async {
let all = knotless::notifications::subscriptions(&self.ctx).await?;
Ok(json!({ "subscriptions": all }))
}
.await,
)
}
async fn reacted(&self, p: React) -> anyhow::Result<Value> {
let on = !p.undo;
let url = repo_url(&p.repo)?;
let reacted = social::react(&self.ctx, &url, &p.uri, &p.content, on).await?;
Ok(json!({"reacted": reacted.mark.is_some(), "uri": reacted.uri()}))
}
#[tool(
description = "React to a pull on a public repository with +1, -1, laugh, hooray, confused, heart, rocket or eyes, or with undo take the reaction back: a dev.knotless.reaction record in your account, which appviews count, and pull_show shows. Reacting so again leaves the reaction there is."
)]
async fn pull_react(
&self,
Parameters(p): Parameters<React>,
) -> Result<CallToolResult, McpError> {
answer(self.reacted(p).await)
}
#[tool(
description = "React to an issue on a public repository with +1, -1, laugh, hooray, confused, heart, rocket or eyes, or with undo take the reaction back: a dev.knotless.reaction record in your account, which appviews count, and issue_show shows. Reacting so again leaves the reaction there is."
)]
async fn issue_react(
&self,
Parameters(p): Parameters<React>,
) -> Result<CallToolResult, McpError> {
answer(self.reacted(p).await)
}
#[tool(
description = "React to a comment on a public repository with +1, -1, laugh, hooray, confused, heart, rocket or eyes, or with undo take the reaction back: a dev.knotless.reaction record in your account, which appviews count, and pull_show and issue_show show. Reacting so again leaves the reaction there is."
)]
async fn comment_react(
&self,
Parameters(p): Parameters<React>,
) -> Result<CallToolResult, McpError> {
answer(self.reacted(p).await)
}
#[tool(
description = "Star a public repository, or with undo take your star back: a dev.knotless.star record in your account, which appviews count. Starring it again leaves the star there is."
)]
async fn repo_star(&self, Parameters(p): Parameters<Star>) -> Result<CallToolResult, McpError> {
answer(
async {
let starred = social::star(&self.ctx, &repo_url(&p.repo)?, !p.undo).await?;
Ok(json!({"starred": starred.mark.is_some(), "uri": starred.uri()}))
}
.await,
)
}
#[tool(
description = "The repositories an account starred, yours by default, the latest first: each as knotless://handle/name, under the name it had when starred."
)]
async fn repo_stars(
&self,
Parameters(p): Parameters<Stars>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let stars = social::stars(&self.ctx, p.handle.as_deref()).await?;
let stars: Vec<Value> = stars
.into_iter()
.map(|s| json!({"repo": s.repo, "uri": s.uri, "createdAt": s.created_at}))
.collect();
Ok(json!({ "stars": stars }))
}
.await,
)
}
#[tool(
description = "What others did that concerns you, the latest first, from the appview: stars of your repositories, follows of you, pulls and issues opened on the repositories you look after (your own, those you collaborate on, and those of organizations you joined), comments and reviews (with their verdict) on your pulls and issues, and pulls, issues and comments that mention you (@handle), pulls that ask you to review them (review-requested), and, on your own pulls, checks that failed at their latest run (checks-failed) and the merge queue turning them away (ejected), each with the checks that did not pass (checks). Each has its repo (handle/name, for pull_show or issue_show), uri (the record written) and subject (the pull or issue it is about: the one a comment is on, else the pull or issue itself; for a star, the repository's record; none for a follow). A comment heard of for another reason that mentions you too says mentioned: true, and a review says how many line comments it holds (lineComments). Each says, as new, whether it was new before this listing; listing them marks them all seen after, unless peek is set, and markedSeen says it did, so the next listing has them new: false."
)]
async fn notification_list(
&self,
Parameters(p): Parameters<Notifications>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let limit = p.limit.unwrap_or(50).clamp(1, 100);
let seen = !p.peek;
let kind = p.kind.as_deref();
let page = knotless::notifications::list(&self.ctx, limit, seen, kind).await?;
let listed: Vec<Value> = page
.notifications
.iter()
.map(|n| {
json!({
"reason": n.reason,
"author": n.author.handle,
"about": n.about(),
"repo": n.repo_name(),
"uri": n.uri,
"subject": n.reason_subject,
"verdict": n.verdict,
"mentioned": n.mentioned,
"lineComments": n.line_comments,
"checks": (!n.checks.is_empty()).then_some(&n.checks),
"new": !n.is_read,
"createdAt": n.created_at,
})
})
.collect();
let marked = seen && kind.is_none() && !listed.is_empty();
Ok(json!({ "notifications": listed, "markedSeen": marked }))
}
.await,
)
}
#[tool(
description = "Follow an account on knotless, or with undo unfollow it: a dev.knotless.follow record in your account, apart from your Bluesky follows, which appviews count. Following it again leaves the follow there is."
)]
async fn account_follow(
&self,
Parameters(p): Parameters<Follow>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let on = !p.undo;
let followed = social::follow(&self.ctx, &p.handle, on).await?;
Ok(json!({"following": followed.mark.is_some(), "uri": followed.uri()}))
}
.await,
)
}
#[tool(
description = "The accounts an account follows on knotless, yours by default, the latest first."
)]
async fn account_follows(
&self,
Parameters(p): Parameters<Follows>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let follows = social::follows(&self.ctx, p.handle.as_deref()).await?;
let follows: Vec<Value> = follows
.into_iter()
.map(|f| json!({"handle": f.handle, "uri": f.uri, "createdAt": f.created_at}))
.collect();
Ok(json!({ "follows": follows }))
}
.await,
)
}
#[tool(
description = "The accounts following an account on knotless, yours by default, as the appview counts them."
)]
async fn account_followers(
&self,
Parameters(p): Parameters<Follows>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let actors = social::followers(&self.ctx, p.handle.as_deref()).await?;
let actors: Vec<Value> = actors
.into_iter()
.map(|a| json!({"did": a.did, "handle": a.handle}))
.collect();
Ok(json!({ "followers": actors }))
}
.await,
)
}
#[tool(
description = "Themes made for knotless's pages, as the appview lists them: everyone's or an account's, the latest first, or with find, those whose name starts so. Each with its at:// URI, its maker, and its palette, to choose with theme_use."
)]
async fn theme_list(
&self,
Parameters(p): Parameters<Themes>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let found = theme::themes(&self.ctx, p.handle.as_deref(), p.find.as_deref(), 100).await?;
let themes: Vec<Value> = found
.into_iter()
.map(|t| json!({"uri": t.uri, "name": t.name, "creator": t.creator.handle, "palette": t.palette, "createdAt": t.created_at}))
.collect();
Ok(json!({ "themes": themes }))
}
.await,
)
}
#[tool(
description = "Whether every text of a palette reads at 4.5 to 1 on what it is drawn on, as the pages hold their own themes, and which falls short."
)]
async fn theme_check(
&self,
Parameters(p): Parameters<ThemeCheck>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let kept = theme::written(&p.palette).map_err(anyhow::Error::msg)?;
let short: Vec<Value> = theme::shortfalls(&kept)
.iter()
.map(|s| json!({"text": s.text, "on": s.on, "ratio": (s.ratio * 100.0).round() / 100.0}))
.collect();
Ok(json!({"readable": short.is_empty(), "shortfalls": short}))
}
.await,
)
}
#[tool(
description = "Publish a palette as a dev.knotless.theme for anyone to choose, once checked (see theme_check); with use, draw your pages in it too. A palette that reads badly is neither published nor used."
)]
async fn theme_publish(
&self,
Parameters(p): Parameters<ThemePublish>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let uri = theme::publish(&self.ctx, &p.name, &p.palette).await?;
let mut said = json!({ "uri": uri });
if p.using {
let choice = Choice::Published(uri);
said["using"] = theme::choose(&self.ctx, &choice).await?.into();
}
Ok(said)
}
.await,
)
}
#[tool(
description = "Draw your pages in a theme, wherever you log in next: system, light, dark, latte, mocha, dracula, pckt, plyr, blacksky, a published theme (its at:// URI), or palette for a palette of your own, given as palette and unpublished."
)]
async fn theme_use(
&self,
Parameters(p): Parameters<ThemeUse>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let choice = match (p.theme.as_str(), p.palette) {
("palette", Some(palette)) => Choice::Palette(palette),
("palette", None) => {
anyhow::bail!("theme palette draws in the palette given, and none is")
}
(named, _) => Choice::parse(named)?,
};
Ok(json!({"using": theme::choose(&self.ctx, &choice).await?}))
}
.await,
)
}
#[tool(description = "Delete a theme you published, by its at:// URI.")]
async fn theme_delete(
&self,
Parameters(p): Parameters<ThemeDelete>,
) -> Result<CallToolResult, McpError> {
answer(
async {
theme::delete(&self.ctx, &p.uri).await?;
Ok(json!({ "deleted": p.uri }))
}
.await,
)
}
#[tool(
description = "Set how you show on knotless, where that differs from your Bluesky profile, and say what your knotless profile now holds. A field left out stays as it is; one set to an empty string is taken out, so your Bluesky profile's shows again. With none, only says what it holds."
)]
async fn account_profile(
&self,
Parameters(p): Parameters<SetProfile>,
) -> Result<CallToolResult, McpError> {
answer(
async {
use base64::Engine;
let avatar = p
.avatar
.map(|a| base64::engine::general_purpose::STANDARD.decode(a));
let change = profile::Change {
name: p.name,
bio: p.bio,
pronouns: p.pronouns,
links: p.links.map(recorded),
avatar: avatar.transpose()?,
bot: p.bot,
};
let me = knotless::session::me(&self.ctx).await?;
let now = profile::set(&self.ctx, &me, change).await?;
Ok(serde_json::to_value(now)?)
}
.await,
)
}
#[tool(
description = "Report an account, a repository, or a pull, issue or comment to the moderation service."
)]
async fn mod_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 (an account its KNOTLESS_ADMIN_DIDS names), take an account, repository, pull, issue or comment down, or with undo put it back."
)]
async fn admin_takedown(
&self,
Parameters(p): Parameters<Takedown>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let subject = target(&p.subject);
let applied = !p.undo;
Admin::new(&self.ctx)
.await?
.takedown(&self.ctx, &subject, applied)
.await
}
.await,
)
}
#[tool(description = "As the appview's operator, whether something is taken down.")]
async fn admin_status(
&self,
Parameters(p): Parameters<Moderated>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let subject = target(&p.subject);
Admin::new(&self.ctx)
.await?
.status(&self.ctx, &subject)
.await
}
.await,
)
}
#[tool(description = "As the appview's operator, the reports it was sent, newest first.")]
async fn admin_reports(
&self,
Parameters(p): Parameters<Reports>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let reports = Admin::new(&self.ctx)
.await?
.reports(p.limit.unwrap_or(50))
.await?;
Ok(json!({"reports": reports}))
}
.await,
)
}
#[tool(
description = "As the appview's operator, the lines it logged, oldest first, as JSON: all of one request's by its trace (the request, the calls it made to PDSes and others, its slow SQL statements, with their times), or the last of a message, a level or a duration. Ask for at least one. Kept across deploys, for 30 days at most, fewer at info. Each read is logged, with who asked."
)]
async fn admin_logs(
&self,
Parameters(p): Parameters<Logs>,
) -> Result<CallToolResult, McpError> {
let asked: Vec<(&str, String)> = [
("trace", p.trace),
("message", p.message),
("level", p.level),
("minMs", p.min_ms.map(|n| n.to_string())),
("limit", p.limit.map(|n| n.to_string())),
]
.into_iter()
.filter_map(|(k, v)| v.map(|v| (k, v)))
.collect();
answer(
async {
let lines = Admin::new(&self.ctx).await?.logs(&asked).await?;
Ok(serde_json::json!({ "lines": lines }))
}
.await,
)
}
#[tool(
description = "As the appview's operator, how it is used, day by day: visitors and page views, requests by route and kind of client, MCP tool calls and why those that failed did, the index's slow statements, crawlers by user agent, clones by repository, CLI versions, sign-up funnels, and what the network wrote (repositories, pushes, pulls, issues, comments, checks, active accounts)."
)]
async fn admin_stats(
&self,
Parameters(p): Parameters<Stats>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let report = Admin::new(&self.ctx)
.await?
.usage(p.days.unwrap_or(14))
.await?;
Ok(serde_json::to_value(report)?)
}
.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, which repo_share does itself, to the appview, when it has not been."
)]
async fn account_delegate(
&self,
Parameters(p): Parameters<Delegate>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let gateway = match p.gateway {
Some(did) => did,
None => gateway::appview(&self.ctx).await?,
};
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 = "What the runners a repository names report of its workflows, newest run first: each check's workflow, status (queued, waiting for a job on its runner; running; success, failure, error, or cancelled for a newer commit's run), ref, commit, steps with their exit codes, why it could not run (error), why a success ran nothing (skipped: the change touched none of the paths its workflow names), when it started and finished (UTC), and the generation of the ref it ran at. A runner keeps a check per workflow for each commit on a ref, or pull, so earlier commits' runs stay listed; a commit run again replaces its own. Ask after a push whether it passed; a check still running has no finishedAt, and a push may take a moment to show: with wait, it waits up to that many seconds for a check to show and for none to be queued or running, and finding none by then is an error naming what was asked for. A commit is its hex ID or at least its first 4 digits. With log, each check carries the end of its output as logText, to see why it failed. With images, the pictures checks made follow as images, such as screenshots of the pages a change moved: what each was, what it is, and where they differ. A repository that names no runners is an error: see runner_list."
)]
async fn run_list(
&self,
Parameters(p): Parameters<Checks>,
) -> Result<CallToolResult, McpError> {
let mut pictures = Vec::new();
let listed = async {
let queue = p.queue.as_ref().map(|b| format!("refs/heads/{b}"));
let commit = p.commit.as_deref().map(checks::commit).transpose()?;
let filter = checks::Filter {
refname: p.refname.as_deref(),
commit: commit.as_deref(),
pull: p.pull.as_deref(),
workflow: p.workflow.as_deref(),
queue: queue.as_deref(),
};
let logs = p.log.unwrap_or(false);
let wait = Duration::from_secs(p.wait.unwrap_or(0).min(MOST_WAIT));
let url = repo_url(&p.repo)?;
let mut value = checks::wait(&self.ctx, &url, filter, logs, wait).await?;
if p.images.unwrap_or(false) {
pictures = checks::pictures(&self.ctx, &value, MOST_PICTURES).await;
}
for check in &mut value {
check.log_text = check.log_text.take().map(tail);
}
Ok(serde_json::to_value(value)?)
}
.await;
let mut answered = answer(listed)?;
use base64::Engine;
for picture in pictures {
let named = format!("{}: {}", picture.workflow, picture.alt);
let data = base64::engine::general_purpose::STANDARD.encode(&picture.bytes);
answered.content.push(ContentBlock::text(named));
answered
.content
.push(ContentBlock::image(data, picture.mime));
}
Ok(answered)
}
#[tool(
description = "Ask a repository's runners, as its owner, to run a ref's or a pull's workflows again, where it points now: give one of ref or pull. workflow runs only that one: of a pull's, such as one that failed alone; at a ref, as one that runs only when asked (`on: {asked: {}}`) does, its steps seeing env. Their new checks follow under run_list."
)]
async fn run_rerun(
&self,
Parameters(p): Parameters<Rerun>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let env = (p.env.into_iter())
.map(|(name, value)| RerunVar { name, value })
.collect();
let (refname, pull) = (p.refname.as_deref(), p.pull.as_deref());
let workflow = p.workflow.as_deref();
let asked = manage::rerun(&self.ctx, &url, refname, pull, workflow, env).await?;
Ok(json!({"asked": asked}))
}
.await,
)
}
#[tool(
description = "Ask a repository's runners, as its owner, to cancel a ref's or a pull's runs going or waiting for their turn when they read it: give one of ref or pull. workflow cancels only that one's runs. Runs of a workflow whose runs finish, as a deploy's, are cancelled too; a merge queue's batches are not (queue_leave takes a pull out). Their checks are filed as cancelled, under run_list."
)]
async fn run_cancel(
&self,
Parameters(p): Parameters<RunCancel>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let (refname, pull) = (p.refname.as_deref(), p.pull.as_deref());
let workflow = p.workflow.as_deref();
let asked = manage::cancel(&self.ctx, &url, refname, pull, workflow).await?;
Ok(json!({"asked": asked}))
}
.await,
)
}
#[tool(
description = "The workflows of a repository at a ref (the default branch if none), in file name order, each with what runs it there: push, a push to it; pull, a pull into it; asked, whether its owner may ask for it by name with run_rerun, ref, workflow and env, as one that runs on a push there, whose `on: {asked: {refs: [...]}}` names it, or that has a schedule, at the default branch; schedule, the `[{cron}]` it runs on there, at the default branch only; and error, why it does not load. Gives the ref in full too."
)]
async fn workflow_list(
&self,
Parameters(p): Parameters<Workflows>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let name = p.refname.as_deref();
let (refname, listed) = landing::workflows(&self.ctx, &url, name).await?;
Ok(json!({"ref": refname, "workflows": listed}))
}
.await,
)
}
/// `dids`, with their handles.
async fn named(&self, dids: Vec<String>) -> anyhow::Result<Value> {
let handles = knotless::identity::handles(&self.ctx, dids.iter().map(String::as_str)).await;
let named: Vec<Value> = dids
.iter()
.map(|did| json!({"did": did, "handle": handles.get(did)}))
.collect();
Ok(Value::Array(named))
}
#[tool(
description = "The runners a repository names: the accounts whose checks of it are shown, as a knotless runner watching it reports them in its own account. Nothing a runner reports shows until it is named, and then what it reported before shows too."
)]
async fn runner_list(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let dids = manage::runners(&self.ctx, &repo_url(&p.repo)?).await?;
self.named(dids).await
}
.await,
)
}
#[tool(
description = "As owner, name an account, by handle, as a runner of a repository. A private repository's runner is a member of it, to read it, and is told of it through the appview; adding it again tells it again. Lists the runners after."
)]
async fn runner_add(
&self,
Parameters(p): Parameters<RepoHandle>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
manage::set_runner(&self.ctx, &url, &p.handle, true).await?;
self.named(manage::runners(&self.ctx, &url).await?).await
}
.await,
)
}
#[tool(
description = "As owner, stop naming an account as a runner of a repository. Lists the runners after."
)]
async fn runner_remove(
&self,
Parameters(p): Parameters<RepoHandle>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
manage::set_runner(&self.ctx, &url, &p.handle, false).await?;
self.named(manage::runners(&self.ctx, &url).await?).await
}
.await,
)
}
#[tool(
description = "The secrets a repository's owner gives its runners, as its workflows' `secrets` list them: each sealed to the runners the repository names, every one or those given, so only they read it, and the appview that holds it holds only ciphertext. Listed by name, runner and refs, never a value."
)]
async fn secret_list(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let listed = knotless::secrets::of(&self.ctx, &repo_url(&p.repo)?).await?;
Ok(serde_json::to_value(listed)?)
}
.await,
)
}
#[tool(
description = "As owner, give the runners a secret: by name with its value, sealed to each runner the repository names, every one or those given in runners, so only they read it, limited to some refs' pushes if given, replacing one of the same name. A value given here passes through this conversation: for a token that should not, the owner runs `knotless secret set <url> <name>` with the value on stdin. A runner added later gets one only once it is set again. Lists the secrets after, by name, runner and refs, never a value."
)]
async fn secret_set(
&self,
Parameters(p): Parameters<SecretSet>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let refs = p.refs.unwrap_or_default();
let only = p.runners.unwrap_or_default();
let value = p.value.as_bytes();
knotless::secrets::set(&self.ctx, &url, &p.name, &refs, &only, value).await?;
let after = knotless::secrets::of(&self.ctx, &url).await?;
Ok(serde_json::to_value(after)?)
}
.await,
)
}
#[tool(
description = "As owner, take back the secret of this name. Lists the secrets after, by name, runner and refs, never a value."
)]
async fn secret_remove(
&self,
Parameters(p): Parameters<RepoName>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
knotless::secrets::remove(&self.ctx, &url, &p.name).await?;
let after = knotless::secrets::of(&self.ctx, &url).await?;
Ok(serde_json::to_value(after)?)
}
.await,
)
}
#[tool(
description = "The agents a repository names: accounts, such as a coding agent, whose pulls its runners check as they move, as they do a collaborator's, but as code no one vouches for: no secrets, caches apart, nothing kept, even if the agent is a collaborator too."
)]
async fn agent_list(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let dids = manage::agents(&self.ctx, &repo_url(&p.repo)?).await?;
self.named(dids).await
}
.await,
)
}
#[tool(
description = "As owner, name an account, by handle, as an agent of a repository (see agent_list). Lists the agents after."
)]
async fn agent_add(
&self,
Parameters(p): Parameters<RepoHandle>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
manage::set_agent(&self.ctx, &url, &p.handle, true).await?;
self.named(manage::agents(&self.ctx, &url).await?).await
}
.await,
)
}
#[tool(
description = "As owner, stop naming an account as an agent of a repository. Lists the agents after."
)]
async fn agent_remove(
&self,
Parameters(p): Parameters<RepoHandle>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
manage::set_agent(&self.ctx, &url, &p.handle, false).await?;
self.named(manage::agents(&self.ctx, &url).await?).await
}
.await,
)
}
#[tool(
description = "The minutes each runner a repository names counts for its owner's public repositories in the last 30 days: per runner, its DID and handle, and `counted` (null where it has counted none): `minutes`, `allowance` (absent with no limit; past it, the owner's runs go one at a time), `freeing` (when minutes stop counting, soonest first) and `updatedAt`. An allowance belongs to the runner: each counts on its own."
)]
async fn runner_minutes(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let found = knotless::checks::minutes(&self.ctx, &repo_url(&p.repo)?).await?;
let dids = found.iter().map(|(d, _)| d.as_str());
let handles = knotless::identity::handles(&self.ctx, dids).await;
let counted: Vec<Value> = found
.iter()
.map(
|(did, m)| json!({"runner": did, "handle": handles.get(did), "counted": m}),
)
.collect();
Ok(Value::Array(counted))
}
.await,
)
}
#[tool(
description = "The protected branches of a repository, whether each lets merge commits in (history), whether pulls land on it only through the merge queue (queue), and the reviews a pull needs to merge into it (reviews: approvals, codeOwners, dismissStale, gates by DID; see branch_protect). A rule asking for code owners has warnings when CODEOWNERS on its branch gives you, the owner, a path alone: an author's approval never counts, so your own pull changing it merges only by force."
)]
async fn branch_rules(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let rules = manage::rules(&self.ctx, &repo_url(&p.repo)?).await?;
self.rules(&p.repo, rules).await
}
.await,
)
}
#[tool(
description = "Delete a repository you own and everything in it: its history, pulls and issues, for good. confirm is its name again."
)]
async fn repo_delete(
&self,
Parameters(p): Parameters<DeleteRepo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
if p.confirm != url.name {
anyhow::bail!("confirm is the repository's name, {}", url.name);
}
manage::delete(&self.ctx, &url).await?;
Ok(json!({"deleted": url.to_string()}))
}
.await,
)
}
#[tool(
description = "Delete every knotless record from your atproto account, for good: your repositories, private ones' spaces with them, and your pulls, issues, comments, stars, follows and the rest; then the appview forgets you and logs you out. Your atproto account, and other apps' records in it, stay. Without confirm, only says what there is: the records of each collection, and your repositories, those others push to or read named, to transfer first (repo_transfer). confirm is your handle."
)]
async fn account_delete_data(
&self,
Parameters(p): Parameters<DeleteAccountData>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let me = knotless::session::me(&self.ctx).await?;
let held = knotless::account::inventory(&self.ctx).await?;
let Some(confirm) = p.confirm else {
return Ok(serde_json::to_value(held)?);
};
if confirm != me.handle {
anyhow::bail!("confirm is your handle, {}", me.handle);
}
let deleted = knotless::account::delete_all(&self.ctx, drop).await?;
Ok(json!({"deleted": deleted, "loggedOut": true}))
}
.await,
)
}
#[tool(
description = "As a repository's owner, fold its older packs into one, and drop claims past their grace period and the records older clients left of deleted refs: what a clone reads gets smaller and quicker. Answers the packs there were before, how many it folded, the claims it dropped, and the deleted refs' records it dropped (refs)."
)]
async fn repo_compact(
&self,
Parameters(p): Parameters<Repo>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let done = compact::compact_repo(&self.ctx, &repo_url(&p.repo)?).await?;
Ok(serde_json::to_value(done)?)
}
.await,
)
}
#[tool(
description = "How a triager's labels compare with everyone else's, scope by scope: the pulls and issues it labelled, how many of those others labelled too, and on how many they agreed. To judge a triage bot before giving it more scopes (see triager_add)."
)]
async fn triager_eval(
&self,
Parameters(p): Parameters<RepoHandle>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let scores = triage::eval(&self.ctx, &url, &p.handle).await?;
Ok(serde_json::to_value(scores)?)
}
.await,
)
}
#[tool(
description = "Find accounts owning public repositories by their handle, as the appview's search does: those whose handle starts with text first. Each with how many public repositories it owns."
)]
async fn account_search(
&self,
Parameters(p): Parameters<SearchOwners>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let limit = p.limit.unwrap_or(10);
let owners = social::owners(&self.ctx, &p.text, limit).await?;
Ok(json!({"owners": owners}))
}
.await,
)
}
#[tool(
description = "Who may read and push a repository. Only its owner can list a private one's readers."
)]
async fn repo_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(
description = "Publish a version of a package from a repository you own: its files, each kept in your account with its size and SHA-256 in one record that never changes. A version published already is refused. Give each file as text or base64."
)]
async fn release_publish(
&self,
Parameters(p): Parameters<Publish>,
) -> Result<CallToolResult, McpError> {
answer(
async {
use base64::Engine;
let mut files = Vec::new();
for f in p.files {
let bytes = match (f.content, f.base64) {
(Some(text), None) => text.into_bytes(),
(None, Some(b64)) => {
base64::engine::general_purpose::STANDARD.decode(b64)?
}
_ => anyhow::bail!("{}: give exactly one of content or base64", f.name),
};
files.push((f.name, release::Content::Bytes(bytes)));
}
let spec = release::Publish {
package: p.package,
version: p.version,
files,
commit: p.commit,
check: p.check,
notes: p.notes,
};
let uri = release::publish(&self.ctx, &repo_url(&p.repo)?, spec).await?;
Ok(json!({ "uri": uri }))
}
.await,
)
}
#[tool(
description = "Withdraw a version of a package from a repository you own, or with undo take that back: it stays there to read and download, so what pins it keeps working, but it is no longer the package's latest or offered to new users. Answers the release's `uri`."
)]
async fn release_yank(
&self,
Parameters(p): Parameters<Yank>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let uri = release::yank(&self.ctx, &url, &p.package, &p.version, p.undo).await?;
Ok(json!({ "uri": uri }))
}
.await,
)
}
#[tool(
description = "A repository's releases, of one package or all: by package, then highest version first, each with its files' names, sizes and SHA-256, the commit it was built from, its notes, whether it is yanked, and whether it is the package's latest (the highest without a pre-release part that is not yanked)."
)]
async fn release_list(
&self,
Parameters(p): Parameters<Releases>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let all = release::list(&self.ctx, &url, p.package.as_deref()).await?;
let latest = |r: &records::ReleaseRecord| {
release::latest(&all, &r.package).is_some_and(|l| l.version == r.version)
};
let listed = all.iter().map(|r| {
let files = r
.files
.iter()
.map(|f| json!({"name": f.name, "size": f.size, "sha256": f.sha256}));
json!({
"package": r.package,
"version": r.version,
"latest": latest(r),
"yanked": r.yanked,
"commit": r.commit,
"check": r.check,
"notes": r.notes,
"createdAt": r.created_at,
"files": files.collect::<Vec<_>>(),
})
});
Ok(Value::Array(listed.collect()))
}
.await,
)
}
#[tool(
description = "Read one file of a release, checked against its record: text as is, a binary file as base64. Files over 1 MB are refused: `knotless release download` fetches those."
)]
async fn release_read(
&self,
Parameters(p): Parameters<ReadRelease>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let all = release::list(&self.ctx, &url, Some(&p.package)).await?;
let found = match &p.version {
Some(v) => all.iter().find(|r| &r.version == v),
None => release::latest(&all, &p.package),
};
let which = p.version.as_deref().unwrap_or("latest");
let Some(r) = found else {
anyhow::bail!("{url} has published no {} {which}", p.package);
};
let Some(file) = r.files.iter().find(|f| f.name == p.file) else {
anyhow::bail!("{} {} has no file {}", p.package, r.version, p.file);
};
if file.size > MOST_RELEASE_READ {
anyhow::bail!(
"{} is {} bytes, more than {MOST_RELEASE_READ} read here: fetch it with `knotless release download`",
file.name,
file.size
);
}
let bytes = release::read(&Remote::open(&self.ctx, &url).await?, file).await?;
Ok(match String::from_utf8(bytes) {
Ok(text) => json!({"version": r.version, "content": text}),
Err(err) => {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(err.into_bytes());
json!({"version": r.version, "base64": b64})
}
})
}
.await,
)
}
}
/// What the server says of itself to a client that starts with it.
const INSTRUCTIONS: &str = "Git repositories stored in atproto accounts. Name a repository as handle/name. Every tool is a `knotless` command: the tool <group>_<action> is `knotless <group> <action>`, with the command's flags as its parameters. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with repo_read if it has one: how that repository wants changes made and checked.
Repositories: repo_list, repo_create, repo_fork, repo_edit, repo_rename, repo_archive, repo_delete, repo_transfer, repo_accept, repo_share, repo_members, repo_star, repo_stars, repo_compact, repo_storage. Read with repo_tree, repo_read, repo_search, repo_blame, repo_log, repo_diff and repo_refs: no clone needed. Change files with repo_commit: no checkout needed. Branches and tags: branch_create, branch_delete, branch_rename, branch_protect (see branch_rules), tag_create, tag_delete.
Collaboration: pull_create (write it from pull_template), pull_list, pull_show, pull_diff, pull_edit, pull_close, pull_reopen, pull_label, pull_assign, pull_ask_review, pull_react, pull_retarget, pull_update_branch (bring its branch up to date with its base), pull_dismiss_review, pull_merge (with queue, into the merge queue: see queue_list and queue_leave) and pull_revert (undo a merged pull). Issues: issue_create (write it from issue_template), issue_list, issue_show, issue_edit, issue_close, issue_reopen, issue_label, issue_assign, issue_react. Comments, which review a pull too: comment_add, comment_edit, comment_delete, comment_resolve, comment_react. Labels: label_list, label_define, label_delete; triagers, accounts whose labels count: triager_list, triager_add, triager_remove, triager_eval.
Resources: a repository's pulls and issues, a pull or an issue, a pull's diff and a file at a revision can be read as MCP resources too, by URIs that mirror their pages on knotless.dev (as knotless://handle/name/pull/<did>/<rkey>: see the resource templates), each answered as the tool that reads it answers.
Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (runner_list, runner_add, runner_remove, runner_minutes) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull; see secret_set, secret_list and secret_remove), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, run_list says whether they passed, workflow_list which a ref has and what runs each, and run_rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Agents, accounts whose pulls the runners check as code no one vouches for: agent_list, agent_add, agent_remove.
Moderation: mod_hide, mod_block, mod_blocks and mod_report. Accounts: account_follow, account_follows, account_followers, account_search, account_profile (how you show on knotless), account_email, account_email_add, account_email_remove (which addresses show commits as yours), account_delegate (let a gateway write for you) and account_delete_data. Themes for knotless's pages: theme_check, theme_publish, theme_use, theme_delete and theme_list. What others did that concerns you: notification_list; choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with notification_watch, notification_subscribe, notification_mute and notification_unsubscribe (see notification_subscriptions). Packages: release_publish a version from a repository, see its versions with release_list, read a file of one with release_read, and withdraw one with release_yank. Organizations: org_add, org_remove, org_join, org_leave, org_members, org_list and org_delete, once you have delegated to a gateway with account_delegate. Operators of a server: admin_takedown, admin_status, admin_reports, admin_stats and admin_logs. status says whether a server is well. An error naming a `knotless` command names a tool here: `knotless org join` is org_join, `knotless pull merge` is pull_merge, and so on, but for login, logout, upgrade, repo mirror, workflow pin and release download, which act on the machine they run on.";
#[tool_handler(router = self.router())]
impl ServerHandler for Server {
async fn call_tool(
&self,
request: rmcp::model::CallToolRequestParams,
context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<CallToolResponse, McpError> {
if let Some(why) = &self.refusal {
return Ok(CallToolResult::error(vec![ContentBlock::text(why)]).into());
}
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
let mut res = self.router().call(tcc).await?;
if let CallToolResponse::Complete(done) = &mut res {
self.advise(done);
}
if let CallToolResponse::Complete(done) = &mut res
&& let Some(newer) = self.notice.lock().unwrap().take()
{
done.content.push(ContentBlock::text(newer));
}
Ok(res)
}
fn get_info(&self) -> ServerConfig {
let can = ServerCapabilities::builder()
.enable_tools()
.enable_resources()
.build();
ServerConfig::new(can)
.with_server_info(Implementation::new("knotless", env!("CARGO_PKG_VERSION")))
.with_instructions(INSTRUCTIONS.to_string())
}
async fn list_resource_templates(
&self,
_request: Option<PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ListResourceTemplatesResult, McpError> {
Ok(ListResourceTemplatesResult::with_all_items(
resources::templates(),
))
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<ReadResourceResponse, McpError> {
let read = self.resource(&request.uri).await?;
Ok(read.into())
}
}
/// Serves MCP over stdin and stdout until the client hangs up; with
/// `read_only`, only the tools in [`READS`].
pub async fn serve_stdio(ctx: Ctx, read_only: bool) -> anyhow::Result<()> {
use rmcp::ServiceExt;
let known = knotless::update::known(&ctx).await;
let server = Server::new(ctx).knowing(&known);
let server = if read_only {
server.read_only()
} else {
server
};
let running = server.serve(rmcp::transport::stdio()).await?;
running.waiting().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn refusals_for_want_of_scope_say_how_to_grant_it() {
let space = "InvalidToken (403): Scope does not permit create in at://did:plc:a/space/dev.knotless.space/r";
let (behind, asked) = (|| true, || false);
assert_eq!(grant(space, &behind), Some(LOG_IN_AGAIN));
// A login that asked for it is not told it is older.
assert_eq!(grant(space, &asked), Some(NOT_LISTED));
let rpc = "ScopeMissingError (403): no";
assert_eq!(grant(rpc, &asked), Some(NOT_GRANTED));
assert_eq!(grant("no such repository", &asked), None);
let dir = tempfile::tempdir().unwrap();
let config = dir.path().to_string_lossy().into_owned();
let env = knotless::env::Env::from_vars(|k| match k {
"KNOTLESS_CONFIG_DIR" => Some(config.clone()),
_ => None,
});
let server = Server::new(Ctx::new(env));
let said = server.failed(&anyhow::anyhow!("{space}"));
assert_eq!(said, format!("{space}\n\n{NOT_LISTED}"));
let said = server.failed(&anyhow::anyhow!("no such repository"));
assert_eq!(said, "no such repository");
// A tool's failure says it as a resource's does.
let mut done = answer(Err(anyhow::anyhow!("{space}"))).unwrap();
server.advise(&mut done);
let text: Vec<_> = done.content.iter().filter_map(|c| c.as_text()).collect();
assert_eq!(text[0].text, format!("{space}\n\n{NOT_LISTED}"));
let mut done = answer(Ok(json!("fine"))).unwrap();
server.advise(&mut done);
let mut other = answer(Err(anyhow::anyhow!("no such repository"))).unwrap();
server.advise(&mut other);
let text: Vec<_> = other.content.iter().filter_map(|c| c.as_text()).collect();
assert_eq!(text[0].text, "no such repository");
}
#[test]
fn a_log_tail_starts_on_a_character() {
assert_eq!(tail("short".into()), "short");
let long = format!("é{}", "x".repeat(LOG_TAIL - 1));
// Cut inside the é, it starts after it.
assert_eq!(tail(long), "x".repeat(LOG_TAIL - 1));
}
#[test]
fn a_diff_numbers_its_lines_as_comments_take_them() {
let diff = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -8,3 +8,3 @@ fn x\n a\n-b\n+c\n d\n@@ -20 +20,2 @@\n-e\n\\ No newline at end of file\n+e\n+f\n";
let want = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -8,3 +8,3 @@ fn x\n 8 8 | a\n 9 | -b\n 9 | +c\n10 10 | d\n@@ -20 +20,2 @@\n20 | -e\n\\ No newline at end of file\n 20 | +e\n 21 | +f\n";
assert_eq!(numbered(diff), want);
// Not a hunk header: left as it is, and nothing after it numbered.
for line in ["@@ -x +1 @@", "@@ -1 1 @@", "@@ -1", "@@ -1 +y @@"] {
let text = format!("{line}\n a\n");
assert_eq!(numbered(&text), text);
}
assert_eq!(numbered(""), "");
}
const TWO_FILES: &str = "preamble\ndiff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1,2 @@\n-x\n+++y\n+z\ndiff --git a/old b/new\nsimilarity index 90%\nrename from old\nrename to new\n@@ -3 +3 @@\n--- w\n+w\n";
#[test]
fn a_diff_parts_by_file() {
let files = sections(TWO_FILES);
let got: Vec<_> = files
.iter()
.map(|f| (f.path, f.added, f.removed, f.text.lines().count()))
.collect();
// A line in a hunk is counted whatever it looks like, `+++` or `---`
// as its text says.
assert_eq!(got, [("a", 2, 1, 7), ("new", 1, 1, 7)]);
assert!(files[0].text.starts_with("diff --git a/a b/a\n"));
assert!(files[1].text.ends_with("+w\n"));
// A header with no `b/` is named as it stands.
assert_eq!(sections("diff --git odd\n")[0].path, "odd");
assert!(sections("no diff\n").is_empty());
}
#[test]
fn a_long_diff_lists_its_files_to_ask_for() {
let whole = pull_diff_answer(TWO_FILES, &[], WHOLE_DIFF).unwrap();
assert_eq!(whole, Value::String(numbered(TWO_FILES)));
let listed = pull_diff_answer(TWO_FILES, &[], 10).unwrap();
assert_eq!(
listed["files"],
json!([
{"path": "a", "added": 2, "removed": 1, "lines": 7},
{"path": "new", "added": 1, "removed": 1, "lines": 7},
])
);
assert!(
listed["said"]
.as_str()
.unwrap()
.contains("15 lines in 2 files")
);
// Asked for by path, a file comes whole whatever the diff's length.
let one = pull_diff_answer(TWO_FILES, &["new".into()], 10).unwrap();
let text = one.as_str().unwrap();
assert!(text.starts_with("diff --git a/old b/new\n"), "{text}");
assert!(text.contains("3 | --- w\n 3 | +w\n"), "{text}");
assert!(!text.contains("b/a"), "{text}");
let err = pull_diff_answer(TWO_FILES, &["a".into(), "gone".into()], 10).unwrap_err();
assert!(err.to_string().contains("no file gone"), "{err}");
}
}