//! 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 { 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> = 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 { 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 { 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])) } }