~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
188 lines · 7.0 KB Raw History
//! 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]))
    }
}