johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! MCP resources: a repository's pulls and issues, a pull or an issue with
//! its comments, a pull's diff and a file at a revision, each by a URI that
//! mirrors its page on knotless.dev under the `knotless://` scheme, and each
//! answered as the tool that reads it answers.
use crate::{File, Items, PullDiff, Server, Subject};
use rmcp::ErrorData as McpError;
use rmcp::model::{ReadResourceResult, ResourceContents, ResourceTemplate};
use serde_json::Value;
const JSON: &str = "application/json";
const TEXT: &str = "text/plain";
/// A resource template, as `resources/templates/list` gives it.
struct Template {
uri: &'static str,
name: &'static str,
title: &'static str,
description: &'static str,
mime: &'static str,
}
const TEMPLATES: [Template; 6] = [
Template {
uri: "knotless://{owner}/{repo}/pulls",
name: "pulls",
title: "Pull requests",
description: "A repository's pull requests, newest first, as pull_list gives them. {owner} is a handle or DID.",
mime: JSON,
},
Template {
uri: "knotless://{owner}/{repo}/issues",
name: "issues",
title: "Issues",
description: "A repository's issues, newest first, as issue_list gives them.",
mime: JSON,
},
Template {
uri: "knotless://{owner}/{repo}/pull/{did}/{rkey}",
name: "pull",
title: "Pull request",
description: "A pull request with its comments and reviews, as pull_show gives it: the pull at://{did}/dev.knotless.pull/{rkey}, as its page names it.",
mime: JSON,
},
Template {
uri: "knotless://{owner}/{repo}/pull/{did}/{rkey}/diff",
name: "pull_diff",
title: "Pull request diff",
description: "The diff a pull request would apply, its lines numbered, as pull_diff gives it: a diff too long to give whole lists its files instead, as JSON, to read with pull_diff's paths.",
mime: TEXT,
},
Template {
uri: "knotless://{owner}/{repo}/issue/{did}/{rkey}",
name: "issue",
title: "Issue",
description: "An issue with its comments, as issue_show gives it: the issue at://{did}/dev.knotless.issue/{rkey}, as its page names it.",
mime: JSON,
},
Template {
uri: "knotless://{owner}/{repo}/file/{rev}/{+path}",
name: "file",
title: "File",
description: "A file at a branch, tag or commit, as repo_read gives it: text as it is, a binary file as a blob. A / in {rev}, as in a branch's name, is written %2F.",
mime: TEXT,
},
];
/// The resource templates.
pub(crate) fn templates() -> Vec<ResourceTemplate> {
let template = |t: &Template| {
ResourceTemplate::new(t.uri, t.name)
.with_title(t.title)
.with_description(t.description)
.with_mime_type(t.mime)
};
TEMPLATES.iter().map(template).collect()
}
/// What a resource's URI asks of the tool that answers it.
pub(crate) enum Read {
Items(Items, &'static str),
Show(Subject),
Diff(PullDiff),
File(File),
}
/// What `uri` asks for, and the tool that answers it: an error naming the
/// templates for a URI none of them makes.
pub(crate) fn parse(uri: &str) -> Result<(&'static str, Read), McpError> {
let unknown = || {
let all: Vec<&str> = TEMPLATES.iter().map(|t| t.uri).collect();
let said = format!(
"{uri} is no knotless resource: expected one of {}",
all.join(", ")
);
McpError::resource_not_found(said, None)
};
// Each part as the template's expansion encoded it, `{+path}`'s `/`
// left as they are.
let parts: Option<Vec<String>> = uri
.strip_prefix("knotless://")
.and_then(|rest| rest.split('/').map(decoded).collect());
let parts = parts.filter(|p| p.iter().all(|s| !s.is_empty()));
let parts = parts.ok_or_else(unknown)?;
let parts: Vec<&str> = parts.iter().map(String::as_str).collect();
let (repo, at) = (
|owner: &str, name: &str| format!("knotless://{owner}/{name}"),
|did: &str, kind: &str, rkey: &str| format!("at://{did}/dev.knotless.{kind}/{rkey}"),
);
Ok(match parts[..] {
[owner, name, "pulls"] => {
let p = Items {
repo: repo(owner, name),
query: None,
};
("pull_list", Read::Items(p, "pull"))
}
[owner, name, "issues"] => {
let p = Items {
repo: repo(owner, name),
query: None,
};
("issue_list", Read::Items(p, "issue"))
}
[owner, name, kind @ ("pull" | "issue"), did, rkey] => {
let p = Subject {
repo: repo(owner, name),
uri: at(did, kind, rkey),
};
let tool = ["pull_show", "issue_show"][usize::from(kind == "issue")];
(tool, Read::Show(p))
}
[owner, name, "pull", did, rkey, "diff"] => {
let p = PullDiff {
repo: repo(owner, name),
uri: at(did, "pull", rkey),
paths: Vec::new(),
};
("pull_diff", Read::Diff(p))
}
[owner, name, "file", rev, ref path @ ..] if !path.is_empty() => {
let p = File {
repo: repo(owner, name),
path: path.join("/"),
rev: Some(rev.to_string()),
start: None,
end: None,
};
("repo_read", Read::File(p))
}
_ => return Err(unknown()),
})
}
/// A URI's part, its percent-escapes decoded: none, if they are no UTF-8.
fn decoded(part: &str) -> Option<String> {
let text = percent_encoding::percent_decode_str(part).decode_utf8();
text.ok().map(std::borrow::Cow::into_owned)
}
impl Server {
/// The resource at `uri`, as the tool that reads it answers: a failure
/// as an error saying what the tool would.
pub(crate) async fn resource(&self, uri: &str) -> Result<ReadResourceResult, McpError> {
if let Some(why) = &self.refusal {
return Err(McpError::invalid_request(why.clone(), None));
}
let (_, read) = parse(uri)?;
let file = matches!(read, Read::File(_));
let answered = match read {
Read::Items(p, kind) => self.items(p, kind).await,
Read::Show(p) => self.show(p).await,
Read::Diff(p) => self.diff(p).await,
Read::File(p) => self.file(p).await,
};
let value = answered.map_err(|err| McpError::internal_error(self.failed(&err), None))?;
let contents = match value {
Value::String(text) => ResourceContents::text(text, uri),
// A binary file, which repo_read gives as base64.
value if file => {
let blob = value["base64"].as_str().unwrap_or_default();
ResourceContents::blob(blob, uri).with_mime_type("application/octet-stream")
}
value => ResourceContents::text(value.to_string(), uri).with_mime_type(JSON),
};
Ok(ReadResourceResult::new(vec![contents]))
}
}